Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions Sources/MarkdownText/UI/Images/BlockImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
17 changes: 16 additions & 1 deletion Sources/MarkdownText/UI/Images/ImageViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ extension View {
func imageViewer(
source: ImageData.Source?,
alt: String,
isPresented: Binding<Bool>
isPresented: Binding<Bool>,
namespace: Namespace.ID
) -> some View {
#if os(iOS)
fullScreenCover(isPresented: isPresented) {
if let source {
ImageViewerView(source: source, alt: alt) {
isPresented.wrappedValue = false
}
.imageZoomTransition(namespace: namespace)
}
}
#else
Expand All @@ -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`.
///
Expand All @@ -51,6 +64,7 @@ struct ImageViewerView: View {
.ignoresSafeArea()

zoomableImage
.ignoresSafeArea()
.accessibilityLabel(alt.isEmpty ? Text(String.imageLabel) : Text(alt))

dismissButton
Expand Down Expand Up @@ -93,6 +107,7 @@ struct ImageViewerView: View {
.background(.ultraThinMaterial, in: Circle())
}
.accessibilityLabel(Text(String.imageViewerCloseLabel))
.accessibilityIdentifier("markdown-image-viewer.close")
.padding(16)
}
Spacer()
Expand Down
18 changes: 14 additions & 4 deletions Sources/MarkdownText/UI/Images/PinchZoomView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
})
Expand All @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions Tests/MarkdownTextTests/ImageViewerTransitionTests.swift
Original file line number Diff line number Diff line change
@@ -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