WinterGram/submodules/InstantPageUI/Sources/InstantImageGalleryItem.swift
isaac 899030184a InstantPageUI: fix photo gallery open transition flying off-screen
InstantImageGalleryItem.animateIn animated the transition surface-copy view
to transformedCopyViewFinalFrame (a point in the gallery item node's own
coordinate space) instead of transformedSurfaceFinalFrame (that point expressed
in the transition surface's space). ChatImageGalleryItem and
UniversalVideoGalleryItem both use the surface-space frame here; only
InstantImageGalleryItem (the InstantView photo item) had the slip.

It was harmless in V1's full-page Instant View because the surface was
window-scale and roughly aligned with the gallery, but in the V2 rich-data
chat bubble the surface is the bubble's container inside the scrolled,
flipped chat list (observed window frame ~ y:-1097 h:2107), so the
gallery-space target landed ~1100pt off-screen and the photo snapshot flew
away on open. Video used UniversalVideoGalleryItem and was unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:23:28 +02:00

355 lines
21 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import PhotoResources
import GalleryUI
import Tuples
private struct InstantImageGalleryThumbnailItem: GalleryThumbnailItem {
let account: Account
let userLocation: MediaResourceUserLocation
let mediaReference: AnyMediaReference
func image(synchronous: Bool) -> (Signal<(TransformImageArguments) -> DrawingContext?, NoError>, CGSize) {
if let imageReferene = mediaReference.concrete(TelegramMediaImage.self), let representation = largestImageRepresentation(imageReferene.media.representations) {
return (mediaGridMessagePhoto(account: self.account, userLocation: self.userLocation, photoReference: imageReferene), representation.dimensions.cgSize)
} else if let fileReference = mediaReference.concrete(TelegramMediaFile.self), let dimensions = fileReference.media.dimensions {
return (mediaGridMessageVideo(postbox: account.postbox, userLocation: self.userLocation, videoReference: fileReference), dimensions.cgSize)
} else {
return (.single({ _ in return nil }), CGSize(width: 128.0, height: 128.0))
}
}
func isEqual(to: GalleryThumbnailItem) -> Bool {
if let to = to as? InstantImageGalleryThumbnailItem {
return self.mediaReference == to.mediaReference
} else {
return false
}
}
}
class InstantImageGalleryItem: GalleryItem {
var id: AnyHashable {
return self.itemId
}
let itemId: AnyHashable
let context: AccountContext
let presentationData: PresentationData
let userLocation: MediaResourceUserLocation
let imageReference: ImageMediaReference
let caption: NSAttributedString
let credit: NSAttributedString
let location: InstantPageGalleryEntryLocation?
let openUrl: (InstantPageUrlItem) -> Void
let openUrlOptions: (InstantPageUrlItem) -> Void
let getPreloadedResource: (String) -> Data?
init(context: AccountContext, presentationData: PresentationData, itemId: AnyHashable, userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference, caption: NSAttributedString, credit: NSAttributedString, location: InstantPageGalleryEntryLocation?, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.itemId = itemId
self.userLocation = userLocation
self.context = context
self.presentationData = presentationData
self.imageReference = imageReference
self.caption = caption
self.credit = credit
self.location = location
self.openUrl = openUrl
self.openUrlOptions = openUrlOptions
self.getPreloadedResource = getPreloadedResource
}
func node(synchronous: Bool) -> GalleryItemNode {
let node = InstantImageGalleryItemNode(context: self.context, presentationData: self.presentationData, openUrl: self.openUrl, openUrlOptions: self.openUrlOptions, getPreloadedResource: self.getPreloadedResource)
node.setImage(userLocation: self.userLocation, imageReference: self.imageReference)
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.position + 1)", "\(location.totalCount)").string))
}
node.setCaption(self.caption, credit: self.credit)
return node
}
func updateNode(node: GalleryItemNode, synchronous: Bool) {
if let node = node as? InstantImageGalleryItemNode {
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.position + 1)", "\(location.totalCount)").string))
}
node.setCaption(self.caption, credit: self.credit)
}
}
func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
return (0, InstantImageGalleryThumbnailItem(account: self.context.account, userLocation: self.userLocation, mediaReference: imageReference.abstract))
}
}
final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode {
private let context: AccountContext
private let imageNode: TransformImageNode
fileprivate let _ready = Promise<Void>()
fileprivate let _title = Promise<String>()
private let footerContentNode: InstantPageGalleryFooterContentNode
private var userLocation: MediaResourceUserLocation?
private var contextAndMedia: (AccountContext, AnyMediaReference)?
private var fetchDisposable = MetaDisposable()
private var getPreloadedResource: (String) -> Data?
init(context: AccountContext, presentationData: PresentationData, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.context = context
self.getPreloadedResource = getPreloadedResource
self.imageNode = TransformImageNode()
self.footerContentNode = InstantPageGalleryFooterContentNode(context: context, presentationData: presentationData)
self.footerContentNode.openUrl = openUrl
self.footerContentNode.openUrlOptions = openUrlOptions
super.init()
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
self.imageNode.view.contentMode = .scaleAspectFill
self.imageNode.clipsToBounds = true
}
deinit {
self.fetchDisposable.dispose()
}
override func ready() -> Signal<Void, NoError> {
return self._ready.get()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
fileprivate func setCaption(_ caption: NSAttributedString, credit: NSAttributedString) {
self.footerContentNode.setCaption(caption, credit: credit)
}
fileprivate func setImage(userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference) {
self.userLocation = userLocation
if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: imageReference.media) {
if let largestSize = largestRepresentationForPhoto(imageReference.media) {
let displaySize = largestSize.dimensions.cgSize.fitted(CGSize(width: 1280.0, height: 1280.0)).dividedByScreenScale().integralFloor
self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets(), emptyColor: .black))()
self.zoomableContent = (largestSize.dimensions.cgSize, self.imageNode)
if let externalResource = largestSize.resource as? InstantPageExternalMediaResource {
var url = externalResource.url
if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") {
url = "https:\(url)"
}
let photoData: Signal<Tuple4<Data?, Data?, ChatMessagePhotoQuality, Bool>, NoError>
if let preloadedData = getPreloadedResource(externalResource.url) {
photoData = .single(Tuple4(nil, preloadedData, .full, true))
} else {
photoData = self.context.engine.resources.httpData(url: url, preserveExactUrl: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<Data?, NoError> in
return .single(nil)
}
|> map { data in
if let data {
return Tuple4(nil, data, .full, true)
} else {
return Tuple4(nil, nil, .full, false)
}
}
}
self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData)
|> map { _, _, generate in
return generate
})
} else {
self.imageNode.setSignal(chatMessagePhoto(postbox: self.context.account.postbox, userLocation: userLocation, photoReference: imageReference), dispatchOnDisplayLink: false)
self.fetchDisposable.set(self.context.engine.resources.fetch(reference: imageReference.resourceReference(largestSize.resource), userLocation: userLocation, userContentType: .image).start())
}
} else {
self._ready.set(.single(Void()))
}
}
self.contextAndMedia = (self.context, imageReference.abstract)
self.footerContentNode.setShareMedia(imageReference.abstract)
}
func setFile(context: AccountContext, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference) {
self.userLocation = userLocation
if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: fileReference.media) {
if let largestSize = fileReference.media.dimensions {
let displaySize = largestSize.cgSize.dividedByScreenScale()
self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets()))()
self.imageNode.setSignal(chatMessageImageFile(account: context.account, userLocation: userLocation, fileReference: fileReference, thumbnail: false), dispatchOnDisplayLink: false)
self.zoomableContent = (largestSize.cgSize, self.imageNode)
} else {
self._ready.set(.single(Void()))
}
}
self.contextAndMedia = (context, fileReference.abstract)
self.footerContentNode.setShareMedia(fileReference.abstract)
}
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view)
let surfaceCopyView = node.2().0!
let copyView = node.2().0!
addToTransitionSurface(surfaceCopyView)
var transformedSurfaceFrame: CGRect?
var transformedSurfaceFinalFrame: CGRect?
if let contentSurface = surfaceCopyView.superview {
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
transformedSurfaceFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: contentSurface)
}
if let transformedSurfaceFrame = transformedSurfaceFrame {
surfaceCopyView.frame = transformedSurfaceFrame
}
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
copyView.frame = transformedSelfFrame
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false)
surfaceCopyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
let positionDuration: Double = 0.21
copyView.layer.animatePosition(from: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak copyView] _ in
copyView?.removeFromSuperview()
})
let scale = CGSize(width: transformedCopyViewFinalFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewFinalFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedSurfaceFinalFrame = transformedSurfaceFinalFrame {
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedSurfaceFinalFrame.midX, y: transformedSurfaceFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
surfaceCopyView?.removeFromSuperview()
})
let scale = CGSize(width: transformedSurfaceFinalFrame.size.width / transformedSurfaceFrame.size.width, height: transformedSurfaceFinalFrame.size.height / transformedSurfaceFrame.size.height)
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
}
self.imageNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.imageNode.layer.position, duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring)
self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
transformedFrame.origin = CGPoint()
self.imageNode.layer.animateBounds(from: transformedFrame, to: self.imageNode.layer.bounds, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
/*self.statusNodeContainer.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusNodeContainer.position, duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)*/
}
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
self.fetchDisposable.set(nil)
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewInitialFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view)
var positionCompleted = false
var boundsCompleted = false
var copyCompleted = false
let copyView = node.2().0!
let surfaceCopyView = node.2().0!
addToTransitionSurface(surfaceCopyView)
var transformedSurfaceFrame: CGRect?
var transformedSurfaceCopyViewInitialFrame: CGRect?
if let contentSurface = surfaceCopyView.superview {
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
transformedSurfaceCopyViewInitialFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: contentSurface)
}
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
copyView.frame = transformedSelfFrame
let intermediateCompletion = { [weak copyView, weak surfaceCopyView] in
if positionCompleted && boundsCompleted && copyCompleted {
copyView?.removeFromSuperview()
surfaceCopyView?.removeFromSuperview()
completion()
}
}
copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, removeOnCompletion: false)
surfaceCopyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.025, removeOnCompletion: false)
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
copyCompleted = true
intermediateCompletion()
})
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedCopyViewInitialFrame = transformedSurfaceCopyViewInitialFrame {
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSurfaceFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSurfaceFrame.size.height)
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
}
self.imageNode.layer.animatePosition(from: self.imageNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
positionCompleted = true
intermediateCompletion()
})
self.imageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, removeOnCompletion: false)
transformedFrame.origin = CGPoint()
self.imageNode.layer.animateBounds(from: self.imageNode.layer.bounds, to: transformedFrame, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
boundsCompleted = true
intermediateCompletion()
})
/*self.statusNodeContainer.layer.animatePosition(from: self.statusNodeContainer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)*/
}
override func visibilityUpdated(isVisible: Bool) {
super.visibilityUpdated(isVisible: isVisible)
if let (context, media) = self.contextAndMedia, let fileReference = media.concrete(TelegramMediaFile.self) {
if isVisible {
self.fetchDisposable.set(context.engine.resources.fetch(reference: fileReference.resourceReference(fileReference.media.resource), userLocation: self.userLocation ?? .other, userContentType: .file).start())
} else {
self.fetchDisposable.set(nil)
}
}
}
override func title() -> Signal<String, NoError> {
return self._title.get()
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
}