| 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 |