diff --git a/Sources/MarkdownText/UI/Images/BlockImageView.swift b/Sources/MarkdownText/UI/Images/BlockImageView.swift index 3f774916..4d8694d3 100644 --- a/Sources/MarkdownText/UI/Images/BlockImageView.swift +++ b/Sources/MarkdownText/UI/Images/BlockImageView.swift @@ -20,16 +20,31 @@ struct BlockImageView: View { @Environment(\.markdownConfig) private var config @State private var isViewerPresented = false + @Namespace private var imageTransition var body: some View { - imageContent + transitionSource .frame(maxWidth: .infinity, alignment: .center) .accessibilityLabel(data.alt.isEmpty ? Text(String.imageLabel) : Text(data.alt)) + .accessibilityIdentifier("markdown-image.inline") .contentShape(Rectangle()) .onTapGesture { handleTap() } - .imageViewer(source: data.source, alt: data.alt, isPresented: $isViewerPresented) + .imageViewer(source: data.source, alt: data.alt, isPresented: $isViewerPresented, namespace: imageTransition) + } + + @ViewBuilder + private var transitionSource: some View { + #if os(iOS) + if #available(iOS 18.0, *) { + imageContent.matchedTransitionSource(id: "image", in: imageTransition) + } else { + imageContent + } + #else + imageContent + #endif } private func handleTap() { diff --git a/Sources/MarkdownText/UI/Images/ImageViewerView.swift b/Sources/MarkdownText/UI/Images/ImageViewerView.swift index 6d0bccc9..27baa16f 100644 --- a/Sources/MarkdownText/UI/Images/ImageViewerView.swift +++ b/Sources/MarkdownText/UI/Images/ImageViewerView.swift @@ -13,7 +13,8 @@ extension View { func imageViewer( source: ImageData.Source?, alt: String, - isPresented: Binding + isPresented: Binding, + namespace: Namespace.ID ) -> some View { #if os(iOS) fullScreenCover(isPresented: isPresented) { @@ -21,6 +22,7 @@ extension View { ImageViewerView(source: source, alt: alt) { isPresented.wrappedValue = false } + .imageZoomTransition(namespace: namespace) } } #else @@ -31,6 +33,17 @@ extension View { #if os(iOS) +private extension View { + @ViewBuilder + func imageZoomTransition(namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, *) { + navigationTransition(.zoom(sourceID: "image", in: namespace)) + } else { + self + } + } +} + /// The built-in fullscreen image viewer presented when a user taps a rendered /// block-level image and `ImageConfig.fullscreenViewerEnabled` is `true`. /// @@ -51,6 +64,7 @@ struct ImageViewerView: View { .ignoresSafeArea() zoomableImage + .ignoresSafeArea() .accessibilityLabel(alt.isEmpty ? Text(String.imageLabel) : Text(alt)) dismissButton @@ -93,6 +107,7 @@ struct ImageViewerView: View { .background(.ultraThinMaterial, in: Circle()) } .accessibilityLabel(Text(String.imageViewerCloseLabel)) + .accessibilityIdentifier("markdown-image-viewer.close") .padding(16) } Spacer() diff --git a/Sources/MarkdownText/UI/Images/PinchZoomView.swift b/Sources/MarkdownText/UI/Images/PinchZoomView.swift index 01c232e1..3479aee3 100644 --- a/Sources/MarkdownText/UI/Images/PinchZoomView.swift +++ b/Sources/MarkdownText/UI/Images/PinchZoomView.swift @@ -26,6 +26,13 @@ struct PinchZoomView: View { private var isScaled: Bool { scale > Self.minZoomScale } + private var usesNativeZoom: Bool { + if #available(iOS 18.0, *) { + return true + } + return false + } + let image: Image let onSwipeToDismiss: () -> Void @@ -41,6 +48,7 @@ struct PinchZoomView: View { .aspectRatio(contentMode: .fit) .scaleEffect(scale * activeScale) .offset(offset) + .accessibilityIdentifier("markdown-image-viewer.image") .background( GeometryReader { proxy in Color.clear @@ -57,8 +65,7 @@ struct PinchZoomView: View { handleDoubleTap(at: location, in: geometry) } .gesture( - SimultaneousGesture( - DragGesture() + DragGesture() .onChanged { value in let deltaWidth = value.translation.width - lastDragDist.width let deltaHeight = value.translation.height - lastDragDist.height @@ -78,7 +85,10 @@ struct PinchZoomView: View { panOffset(velocity: value.velocity, containerSize: geometry.size) } }, - MagnificationGesture() + including: isScaled || !usesNativeZoom ? .all : .none + ) + .simultaneousGesture( + MagnificationGesture() .updating($activeScale, body: { value, state, _ in state = value }) @@ -89,9 +99,9 @@ struct PinchZoomView: View { animateOffset(to: newOffset) } } - ) ) } + .interactiveDismissDisabled(usesNativeZoom && (isScaled || activeScale != Self.minZoomScale)) } private func handleDoubleTap(at location: CGPoint, in geometry: GeometryProxy) { diff --git a/Tests/MarkdownTextTests/ImageViewerTransitionTests.swift b/Tests/MarkdownTextTests/ImageViewerTransitionTests.swift new file mode 100644 index 00000000..23f5d092 --- /dev/null +++ b/Tests/MarkdownTextTests/ImageViewerTransitionTests.swift @@ -0,0 +1,81 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +#if os(iOS) +@testable import SwiftStreamingMarkdown +import SwiftUI +import UIKit +import XCTest + +@MainActor +final class ImageViewerTransitionTests: XCTestCase { + func test_builtin_viewer_uses_native_zoom_transition() async throws { + guard #available(iOS 18.0, *) else { + throw XCTSkip("Native zoom transitions require iOS 18") + } + let controller = UIHostingController(rootView: ImageViewerFixture()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + controller.dismiss(animated: false) + window.rootViewController = nil + } + let presented = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in controller.presentedViewController != nil }, + object: controller + ) + + await fulfillment(of: [presented], timeout: 5) + + let viewer = try XCTUnwrap(controller.presentedViewController) + XCTAssertNotNil(viewer.preferredTransition) + } +} + +private struct ImageViewerFixture: View { + @Namespace private var namespace + @State private var isPresented = true + + var body: some View { + Image(systemName: "photo") + .resizable() + .frame(width: 200, height: 120) + .matchedImageSource(namespace: namespace) + .imageViewer( + source: .bundledResource(fileName: "sample-landscape", ext: "png"), + alt: "Landscape", + isPresented: $isPresented, + namespace: namespace + ) + .environment(\.markdownController, MarkdownController(listener: TransitionResourceListener())) + } +} + +private extension View { + @ViewBuilder + func matchedImageSource(namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, *) { + matchedTransitionSource(id: "image", in: namespace) + } else { + self + } + } +} + +private final class TransitionResourceListener: MarkdownListener { + func onRender(markdown: RenderableDocument) async {} + func onTableCopyTap(content: String) async {} + func onTableDownloadTap(content: String) async {} + func onContextMenuAppear(id: String, selectedContent: String) async {} + func onContextMenuTap(id: String, selectedContent: String) async {} + func onImageTap(image: MarkdownImage) async {} + + func resolveBundledResource(fileName: String, ext: String?) -> URL? { + Bundle.module.url(forResource: fileName, withExtension: ext) + } +} +#endif