From 44e02717a5dc200233fd0eae4abafbf185fe0a4b Mon Sep 17 00:00:00 2001 From: linzhiyi Date: Sun, 11 Oct 2026 18:44:47 +0800 Subject: [PATCH 1/2] Add native zoom transitions to the built-in image viewer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +++ .../UI/Images/BlockImageView.swift | 19 ++++- .../UI/Images/ImageViewerView.swift | 17 +++- .../UI/Images/PinchZoomView.swift | 18 ++++- .../ImageViewerTransitionTests.swift | 81 +++++++++++++++++++ 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 Tests/MarkdownTextTests/ImageViewerTransitionTests.swift diff --git a/README.md b/README.md index efae63a8..43806d52 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,16 @@ MarkdownView(text: source, listener: AnalyticsListener()) The listener is propagated through the SwiftUI environment, so deeply nested rendered subviews observe the same hooks. +### Image viewer transitions + +On iOS 18 and later, tapping an image opens the built-in fullscreen viewer with +a native zoom transition from that specific image occurrence. Dismissal zooms +back to the same occurrence, even when a resource appears more than once. +At minimum image zoom, the system handles interactive dismissal; while zoomed +or pinching, gestures remain available for inspecting the image. No host-provided +viewer or transition source is required. iOS 16 and 17 retain the existing +fullscreen presentation and swipe-to-dismiss behavior. + ## Sample app A SwiftUI sample app lives in 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 From 57ba96d3d3fee0cf0e7242fe7816f060b690af34 Mon Sep 17 00:00:00 2001 From: linzhiyi Date: Sun, 11 Oct 2026 19:43:06 +0800 Subject: [PATCH 2/2] Keep the upstream README unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.md b/README.md index 43806d52..efae63a8 100644 --- a/README.md +++ b/README.md @@ -280,16 +280,6 @@ MarkdownView(text: source, listener: AnalyticsListener()) The listener is propagated through the SwiftUI environment, so deeply nested rendered subviews observe the same hooks. -### Image viewer transitions - -On iOS 18 and later, tapping an image opens the built-in fullscreen viewer with -a native zoom transition from that specific image occurrence. Dismissal zooms -back to the same occurrence, even when a resource appears more than once. -At minimum image zoom, the system handles interactive dismissal; while zoomed -or pinching, gestures remain available for inspecting the image. No host-provided -viewer or transition source is required. iOS 16 and 17 retain the existing -fullscreen presentation and swipe-to-dismiss behavior. - ## Sample app A SwiftUI sample app lives in