eleven / ocho Public

iOS: photo lightbox zooms from its bubble, edge-to-edge, Messages-style pinch (#608)

* ios: photo lightbox zooms out of its bubble, Messages-style manipulation

The tapped photo now zooms OUT OF ITS BUBBLE to an edge-to-edge fit
(the Mac lightbox's grammar), instead of the fullScreenCover's default
bottom-up slide: the bubble frame MessageBubble already captured is
threaded through IdentifiedImage, the cover presents with its transition
disabled, and the viewer animates bubble->fullscreen itself. Closing —
tap, X, or swipe-down — zooms it back into place.

While open the photo is freely manipulable like Messages: pinch zooms
about the fingers and KEEPS its zoom (rubber give past 1x/5x, springs
back into range on release), drag pans while zoomed (letterbox rubber-
banded back out), twist rotates 1:1 and springs level, double-tap
toggles 2.5x at the tapped spot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: bubble frame readable at tap time — anchor UIView, no geometry observation

The zoom-from-bubble was starting mid-screen on iOS: trackingFrame(into:)
was compiled out there (the #580 layout-churn fix, from when only the Mac
read the frame), so the lightbox always got .zero and used its fallback.

Keep the #580 win — still no onChange(of: CGRect) observation on iOS.
Instead a passive UIView sits in the card's background and LiveFrame
converts its bounds to window coordinates lazily, only at the moment of
the tap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3f3e96e109ba1ffb6707b3d5e2ac4f408e409f9c parent 5d7c516
2 files changed, +244 −90
  • ios/LChat/Views/NativeChatScreen.swift +191 −69
  • ios/LChatCore/Sources/LChatCore/LiveFrame.swift +53 −21
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 3989656..8b6551d 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -105,6 +105,7 @@ struct NativeChatScreen: View {
struct IdentifiedImage: Identifiable {
let id: String
let image: PlatformImage
+ let sourceFrame: CGRect // the bubble thumbnail's window frame — where the zoom starts
}
struct PlayingVideo: Identifiable {
@@ -385,11 +386,13 @@ struct NativeChatScreen: View {
}
}
.fullScreenCover(item: $viewerImage) { item in
- ImageViewer(image: item.image) { dismissCover { viewerImage = nil } }
- .presentationBackground(.clear)
+ ImageViewer(image: item.image, sourceFrame: item.sourceFrame) {
+ coverWithoutSlide { viewerImage = nil }
+ }
+ .presentationBackground(.clear)
}
.fullScreenCover(item: $playingVideo) { item in
- VideoViewer(player: item.player) { dismissCover { playingVideo = nil } }
+ VideoViewer(player: item.player) { coverWithoutSlide { playingVideo = nil } }
.presentationBackground(.clear)
}
// Tapping a file chip previews it in Quick Look (with the system
@@ -404,13 +407,14 @@ struct NativeChatScreen: View {
}
}
- // The viewers animate their own exits (the image flies off, the backdrop
- // fades) — the cover must vanish WITHOUT its slide-down, or the whole
- // lightbox visibly swipes away with the photo.
- private func dismissCover(_ clear: @escaping () -> Void) {
+ // The viewers own their whole choreography — the photo zooms out of (and
+ // back into) its bubble, the video flies off in its swipe direction — so
+ // the cover must both APPEAR and VANISH without its default slide, or the
+ // system transition visibly fights the viewer's own animation.
+ private func coverWithoutSlide(_ change: @escaping () -> Void) {
var t = Transaction()
t.disablesAnimations = true
- withTransaction(t, clear)
+ withTransaction(t, change)
}
// The feed hands the lightbox its downsampled thumbnail so the zoom opens
@@ -421,8 +425,9 @@ struct NativeChatScreen: View {
guard let source = model.blobSource(att) else { return }
Task {
guard let full = await BlobImageLoader.shared.image(source),
- viewerImage?.id == att.id else { return }
- viewerImage = IdentifiedImage(id: att.id, image: full)
+ let current = viewerImage, current.id == att.id else { return }
+ viewerImage = IdentifiedImage(id: att.id, image: full,
+ sourceFrame: current.sourceFrame)
}
}
@@ -752,8 +757,11 @@ struct NativeChatScreen: View {
onJump: { target in jumpAndWave(target, proxy: proxy) },
onOpenThread: { root in openThread(root) },
blobSource: { model.blobSource($0) },
- onImageTap: { att, image, _ in
- viewerImage = IdentifiedImage(id: att.id, image: image)
+ onImageTap: { att, image, frame in
+ coverWithoutSlide {
+ viewerImage = IdentifiedImage(
+ id: att.id, image: image, sourceFrame: frame)
+ }
upgradeViewerImage(att)
},
onFileTap: { openFile($0) },
@@ -863,8 +871,11 @@ struct NativeChatScreen: View {
senderName: model.senderName(m),
theme: .named(themeName),
blobSource: { model.blobSource($0) },
- onImageTap: { att, image, _ in
- viewerImage = IdentifiedImage(id: att.id, image: image)
+ onImageTap: { att, image, frame in
+ coverWithoutSlide {
+ viewerImage = IdentifiedImage(
+ id: att.id, image: image, sourceFrame: frame)
+ }
upgradeViewerImage(att)
},
onFileTap: { openFile($0) },
@@ -1573,76 +1584,152 @@ struct NativeChatScreen: View {
}
}
-// The full-screen photo viewer, Photos-style: pinch to zoom (with pan and
-// double-tap toggle), swipe down to dismiss — the photo follows the finger and
-// the backdrop fades with the drag.
+// The full-screen photo viewer, Messages-style. The tapped photo ZOOMS OUT OF
+// ITS BUBBLE to an edge-to-edge fit over a fading black scrim (the cover
+// itself presents with no transition — this view is the whole choreography),
+// and closing zooms it back into place. While open it's freely manipulable:
+// pinch zooms about the fingers and KEEPS its zoom, drag pans while zoomed
+// (or pulls to dismiss at 1:1, the photo following the finger), twist rotates
+// 1:1 and springs back level, double-tap toggles zoom at the tapped spot.
private struct ImageViewer: View {
let image: PlatformImage
+ let sourceFrame: CGRect // the bubble thumbnail, in window coordinates
let onClose: () -> Void
+ // Drives the scrim and the bubble↔fullscreen geometry (the Mac lightbox's
+ // pattern): sprung on in onAppear, off in close() with delayed teardown.
+ @State private var shown = false
+ @State private var closing = false
+
+ // The gesture pose, applied on top of the fitted geometry.
@State private var scale: CGFloat = 1
@State private var pinchBase: CGFloat = 1
@State private var offset: CGSize = .zero
@State private var panBase: CGSize = .zero
- @State private var dismissOffset: CGFloat = 0
@State private var rotation: Angle = .zero
+ @State private var dismissOffset: CGFloat = 0
+ private let maxZoom: CGFloat = 5
private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) }
- @State private var exiting = false
var body: some View {
ZStack(alignment: .topTrailing) {
+ GeometryReader { geo in
+ stage(in: geo.size)
+ }
+ // Edge-to-edge: the stage spans the whole screen, so its local
+ // coordinates are window coordinates — the space sourceFrame is in.
+ .ignoresSafeArea()
+ // The close button stays inside the safe area, above the stage.
+ if shown, !closing, dismissOffset == 0 {
+ Button(action: close) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(.white.opacity(0.8))
+ .padding()
+ }
+ .accessibilityLabel("Close")
+ .transition(.opacity)
+ }
+ }
+ // Sprung from onAppear (not init) so the first frame still renders the
+ // photo exactly over its bubble — a seamless start to the zoom.
+ .onAppear {
+ withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true }
+ }
+ }
+
+ private func stage(in viewport: CGSize) -> some View {
+ let target = fittedRect(in: viewport)
+ // A frame that never got captured (shouldn't happen) degrades to a
+ // fade-up from center rather than a zoom from nowhere.
+ let src = sourceFrame.isEmpty
+ ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
+ : sourceFrame
+ return ZStack {
// The backdrop FADES (fully — the chat shows through the clear
- // cover); only the image itself rides the swipe.
- Color.black.opacity(exiting ? 0 : Double(1 - dismissProgress * 0.9))
- .ignoresSafeArea()
+ // cover); only the photo itself rides the swipe.
+ Color.black
+ .opacity(shown ? Double(1 - dismissProgress * 0.9) : 0)
.onTapGesture(perform: close)
Image(platform: image)
.resizable()
- .scaledToFit()
+ .interpolation(.high)
+ .frame(width: shown ? target.width : src.width,
+ height: shown ? target.height : src.height)
+ .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous))
.scaleEffect(scale * (1 - dismissProgress * 0.15))
.rotationEffect(rotation)
- .offset(x: offset.width, y: offset.height + dismissOffset)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .onTapGesture(count: 2) {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
- if scale > 1 {
- scale = 1
- offset = .zero
- panBase = .zero
- } else {
- scale = 2.5
- }
- }
+ .position(x: (shown ? target.midX : src.midX) + offset.width,
+ y: (shown ? target.midY : src.midY) + offset.height + dismissOffset)
+ .onTapGesture(count: 2) { loc in
+ doubleTap(at: loc, target: target, viewport: viewport)
}
- .gesture(drag)
- .simultaneousGesture(pinch)
+ .onTapGesture { if scale == 1 { close() } }
+ .gesture(drag(target: target, viewport: viewport))
+ .simultaneousGesture(pinch(target: target, viewport: viewport))
.simultaneousGesture(rotate)
- if dismissOffset == 0, !exiting {
- Button(action: close) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 28))
- .foregroundStyle(.white.opacity(0.8))
- .padding()
- }
- }
}
}
- // The image flies off in its swipe direction while the backdrop fades out,
- // THEN the (animation-less) cover clears.
+ // Aspect-fit to the FULL screen — the photo meets the edges (scaled up if
+ // needed, like Messages), letterboxed by the scrim on the other axis.
+ private func fittedRect(in viewport: CGSize) -> CGRect {
+ let size = image.size
+ guard size.width > 0, size.height > 0, viewport.width > 0, viewport.height > 0 else {
+ return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
+ }
+ let s = min(viewport.width / size.width, viewport.height / size.height)
+ let w = size.width * s
+ let h = size.height * s
+ return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2,
+ width: w, height: h)
+ }
+
+ // Everything springs home together — the pose to identity, the geometry
+ // back into the bubble, the scrim out — then the animation-less cover
+ // clears once the zoom-back has played.
private func close() {
- withAnimation(.easeIn(duration: 0.18)) {
- exiting = true
- if dismissOffset != 0 {
- dismissOffset = dismissOffset > 0 ? 900 : -900
+ guard !closing else { return }
+ closing = true
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) {
+ shown = false
+ scale = 1
+ offset = .zero
+ rotation = .zero
+ dismissOffset = 0
+ }
+ pinchBase = 1
+ panBase = .zero
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose)
+ }
+
+ // Zoom in about the tapped spot; zoom out back to the fit.
+ private func doubleTap(at loc: CGPoint, target: CGRect, viewport: CGSize) {
+ if scale > 1 {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
+ scale = 1
+ offset = .zero
+ }
+ } else {
+ // q: the tapped point in the photo's own (unscaled, centered)
+ // coordinates — hold it in place while the photo grows around it.
+ let q = CGPoint(x: (loc.x - target.midX - offset.width) / scale,
+ y: (loc.y - target.midY - offset.height) / scale)
+ let zoomed: CGFloat = 2.5
+ let held = CGSize(width: offset.width + (scale - zoomed) * q.x,
+ height: offset.height + (scale - zoomed) * q.y)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
+ scale = zoomed
+ offset = clamped(held, scale: zoomed, fitted: target.size, in: viewport)
}
}
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: onClose)
+ pinchBase = scale
+ panBase = offset
}
// One drag, two meanings: pan while zoomed in, pull-to-dismiss at 1:1.
- private var drag: some Gesture {
+ private func drag(target: CGRect, viewport: CGSize) -> some Gesture {
DragGesture()
.onChanged { v in
if scale > 1 {
@@ -1654,6 +1741,11 @@ private struct ImageViewer: View {
}
.onEnded { v in
if scale > 1 {
+ // Rubber-band any letterbox dragged into view back out.
+ withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
+ offset = clamped(offset, scale: scale,
+ fitted: target.size, in: viewport)
+ }
panBase = offset
} else if abs(dismissOffset) > 120 || abs(v.predictedEndTranslation.height) > 400 {
close()
@@ -1665,31 +1757,61 @@ private struct ImageViewer: View {
}
}
- // Instagram-style peek zoom: pinch stretches the photo (with a little
- // over-zoom give), and RELEASING springs the whole pose — zoom, pan and
- // twist — back home. Double-tap remains the way to zoom and stay zoomed.
- private var pinch: some Gesture {
- MagnificationGesture()
- .onChanged { v in scale = min(5, max(0.8, pinchBase * v)) }
+ // Messages-style pinch: the photo point under the fingers stays under
+ // them, and the zoom KEEPS on release — clamped back into [1, maxZoom]
+ // (with rubber give past both ends while the fingers are down).
+ private func pinch(target: CGRect, viewport: CGSize) -> some Gesture {
+ MagnifyGesture()
+ .onChanged { v in
+ let s = softClamped(pinchBase * v.magnification)
+ let q = CGPoint(x: (v.startLocation.x - target.midX - panBase.width) / pinchBase,
+ y: (v.startLocation.y - target.midY - panBase.height) / pinchBase)
+ scale = s
+ offset = CGSize(width: panBase.width + (pinchBase - s) * q.x,
+ height: panBase.height + (pinchBase - s) * q.y)
+ }
.onEnded { _ in
- withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
- scale = 1
- offset = .zero
+ let settled = min(maxZoom, max(1, scale))
+ withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
+ scale = settled
+ offset = settled == 1
+ ? .zero
+ : clamped(offset, scale: settled, fitted: target.size, in: viewport)
}
- panBase = .zero
- pinchBase = 1
+ pinchBase = settled
+ panBase = offset
}
}
- // Twist while zooming — RESISTANT (the photo turns at half your fingers'
- // angle, like it has some weight) and springs back level on release.
+ // Twist turns the photo 1:1 under the fingers (Messages-style) and springs
+ // back level on release — the zoom stays, the tilt never does.
private var rotate: some Gesture {
- RotationGesture()
- .onChanged { rotation = Angle(degrees: $0.degrees * 0.45) }
+ RotateGesture()
+ .onChanged { rotation = $0.rotation }
.onEnded { _ in
- withAnimation(.spring(response: 0.45, dampingFraction: 0.62)) { rotation = .zero }
+ withAnimation(.spring(response: 0.45, dampingFraction: 0.72)) {
+ rotation = .zero
+ }
}
}
+
+ // Rubber give past the zoom limits: the photo keeps responding at a
+ // root-curve fraction of the overshoot, and the release springs it back.
+ private func softClamped(_ raw: CGFloat) -> CGFloat {
+ if raw > maxZoom { return maxZoom * pow(raw / maxZoom, 0.4) }
+ if raw < 1 { return max(0.55, pow(raw, 0.6)) }
+ return raw
+ }
+
+ // Pan bounds: no dead letterbox on an axis where the zoomed photo exceeds
+ // the screen; centered on an axis where it doesn't.
+ private func clamped(_ o: CGSize, scale: CGFloat, fitted: CGSize,
+ in viewport: CGSize) -> CGSize {
+ let maxX = max(0, (fitted.width * scale - viewport.width) / 2)
+ let maxY = max(0, (fitted.height * scale - viewport.height) / 2)
+ return CGSize(width: min(maxX, max(-maxX, o.width)),
+ height: min(maxY, max(-maxY, o.height)))
+ }
}
// Full-screen encrypted-video playback — an AVPlayerViewController wrapped so
diff --git a/ios/LChatCore/Sources/LChatCore/LiveFrame.swift b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift
index 924b9f2..c52c0a0 100644
--- a/ios/LChatCore/Sources/LChatCore/LiveFrame.swift
+++ b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift
@@ -1,42 +1,74 @@
import SwiftUI
+#if canImport(UIKit)
+import UIKit
+#endif
-// 🤖 A card's live `.global` frame, for the Mac lightbox zoom-out (issue #580).
+// 🤖 A card's live window frame, read at tap time — the lightbox zooms out
+// from it on both platforms (Mac: issue #580; iOS: the same zoom).
//
-// The image / video / YouTube cards need the thumbnail's window frame ONLY at
-// tap time, and ONLY on the Mac (the lightbox zooms out from it — iOS ignores
-// the value entirely: every `onImageTap` consumer takes `_`).
+// The frame must NOT be tracked with a GeometryReader + `onChange(of:)`:
+// that's a continuous observation of a value that changes on EVERY layout
+// pass — and a chat open runs several layout passes per displayed frame
+// (scroll-settle pin, avatar spring, composer inset, keyboard), so SwiftUI
+// flags "onChange(of: CGRect) action tried to update multiple times per
+// frame": real main-thread layout churn (a #580 stall suspect). Moving the
+// write off @State was NOT enough — the loop wasn't self-invalidation, it's
+// that we were observing a high-frequency value at all.
//
-// The frame was tracked with a GeometryReader + `onChange(of:)`. That's a
-// continuous observation of a value that changes on EVERY layout pass — and a
-// chat open runs several layout passes per displayed frame (scroll-settle pin,
-// avatar spring, composer inset, keyboard), so the frame changes several times
-// per frame and SwiftUI flags "onChange(of: CGRect) action tried to update
-// multiple times per frame": real main-thread layout churn (a #580 stall
-// suspect). Moving the write off @State was NOT enough — the loop wasn't
-// self-invalidation, it's that we were observing a high-frequency value at all.
-//
-// So the tracker is compiled out on iOS (the value is dead there) via
-// `trackingFrame(into:)`, and kept on macOS where the lightbox needs it. The
-// holder stays a plain reference so the write never invalidates the view.
+// So on iOS nothing observes anything: a passive UIView sits in the card's
+// background and `rect` asks IT for its window frame only at the moment of
+// the tap. macOS keeps the GeometryReader tracker (AppKit backing views
+// aren't reachable the same way there, and the Mac feed never hit the churn).
+// The holder stays a plain reference so no write ever invalidates the view.
// Only ever touched on the main actor (SwiftUI view code), hence @MainActor.
@MainActor
final class LiveFrame {
+ #if canImport(UIKit)
+ weak var anchor: UIView?
+ var rect: CGRect {
+ guard let anchor, anchor.window != nil else { return .zero }
+ return anchor.convert(anchor.bounds, to: nil) // window coords == SwiftUI .global
+ }
+ #else
var rect: CGRect = .zero
+ #endif
}
extension View {
- /// Keep `holder.rect` current with this view's `.global` frame — macOS only
- /// (the only platform that reads it; see LiveFrame). A no-op on iOS, which
- /// is what removes the layout-churn `onChange(of: CGRect)` there.
+ /// Keep `holder.rect` answerable with this view's window frame. macOS
+ /// tracks it reactively; iOS plants a passive anchor view and computes the
+ /// frame lazily at tap time (no `onChange(of: CGRect)` layout churn — see
+ /// LiveFrame).
func trackingFrame(into holder: LiveFrame) -> some View {
#if os(macOS)
- background(GeometryReader { g in
+ return background(GeometryReader { g in
Color.clear
.onAppear { holder.rect = g.frame(in: .global) }
.onChange(of: g.frame(in: .global)) { _, f in holder.rect = f }
})
#else
- self
+ return background(FrameAnchor(holder: holder))
#endif
}
}
+
+#if canImport(UIKit)
+// The passive anchor: fills the card (as its background), never draws, never
+// participates in touch — exists only so LiveFrame can convert its bounds to
+// window coordinates on demand.
+private struct FrameAnchor: UIViewRepresentable {
+ let holder: LiveFrame
+
+ func makeUIView(context: Context) -> UIView {
+ let v = UIView()
+ v.isUserInteractionEnabled = false
+ v.backgroundColor = .clear
+ holder.anchor = v
+ return v
+ }
+
+ func updateUIView(_ v: UIView, context: Context) {
+ holder.anchor = v
+ }
+}
+#endif