Share expo-image cache; thumb placeholder; smoother dismiss

Two UX fixes for the peek preview:

1. Black flash on first peek — the preview VC now depends on SDWebImage
   directly (same version pinned by expo-image) and reads from
   `SDImageCache.shared`. On open it paints the fullsize synchronously if it's
   cached (common, since onPressIn prefetches), otherwise paints the thumb as a
   placeholder while the fullsize loads and swaps in. Also switches the image
   view to autoresizingMask so the dismiss animation interpolates cleanly
   without AutoLayout relayout mid-animation.

2. Two-stage dismiss animation on aspect-mismatched thumbnails — drop the
   explicit `previewForDismissingMenuWithConfiguration` override. With only
   the highlight provider implemented iOS reverses it end-to-end, which
   interpolates size and position together instead of snapping back to the
   preview's original size before translating.

https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT
This commit is contained in:
Claude
2026-04-19 08:29:54 +00:00
committed by Samuel Newman
parent 2dc22c19b0
commit 1856c36221
9 changed files with 80 additions and 29 deletions
@@ -10,6 +10,8 @@ Pod::Spec.new do |s|
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
# Must match the version pinned by expo-image so we share SDImageCache.shared.
s.dependency 'SDWebImage', '~> 5.21.0'
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
@@ -59,12 +59,11 @@ class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate {
return makeTargetedPreview() return makeTargetedPreview()
} }
func contextMenuInteraction( // NOTE: Intentionally not implementing
_ interaction: UIContextMenuInteraction, // `previewForDismissingMenuWithConfiguration`. iOS reverses the highlight
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration // animation by default, which interpolates size + position together. Supplying
) -> UITargetedPreview? { // a separate dismiss preview tends to produce a two-stage animation on
return makeTargetedPreview() // aspect-mismatched thumbnails (snap to original size, then translate).
}
func contextMenuInteraction( func contextMenuInteraction(
_ interaction: UIContextMenuInteraction, _ interaction: UIContextMenuInteraction,
@@ -1,3 +1,4 @@
import SDWebImage
import UIKit import UIKit
/// Preview view controller shown during a peek. Renders a single image sized /// Preview view controller shown during a peek. Renders a single image sized
@@ -6,12 +7,25 @@ import UIKit
/// The aspect ratio drives `preferredContentSize` so iOS animates directly to /// The aspect ratio drives `preferredContentSize` so iOS animates directly to
/// the final size without the mid-flight stretch that happens when a mis-sized /// the final size without the mid-flight stretch that happens when a mis-sized
/// snapshot is scaled up. /// snapshot is scaled up.
///
/// Image loading cooperates with expo-image by sharing
/// `SDImageCache.shared` and `SDWebImageManager.shared`:
/// 1. Query the cache synchronously for the fullsize if it's there
/// (e.g. prefetched on press-in), paint it immediately.
/// 2. Else, paint the thumbnail (almost always cached it's what the feed
/// renders) as a placeholder.
/// 3. Asynchronously load the fullsize and swap it in when it arrives.
/// This eliminates the "black flash" on first peek of an unloaded image.
final class ImagePreviewController: UIViewController { final class ImagePreviewController: UIViewController {
private let imageURL: URL? private let imageURL: URL?
private let thumbURL: URL?
private let aspectRatio: CGFloat private let aspectRatio: CGFloat
init(imageURL: URL?, aspectRatio: CGFloat) { private let imageView = UIImageView()
init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) {
self.imageURL = imageURL self.imageURL = imageURL
self.thumbURL = thumbURL
self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1 self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
self.preferredContentSize = Self.sizeForAspect(self.aspectRatio) self.preferredContentSize = Self.sizeForAspect(self.aspectRatio)
@@ -24,36 +38,54 @@ final class ImagePreviewController: UIViewController {
root.backgroundColor = .black root.backgroundColor = .black
root.clipsToBounds = true root.clipsToBounds = true
let imageView = UIImageView() // Use autoresizing mask rather than AutoLayout so the imageView's frame
// interpolates cleanly during the dismiss animation AutoLayout-driven
// relayout during a CALayer animation can cause a visible snap.
imageView.frame = root.bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.backgroundColor = .black imageView.backgroundColor = .black
root.addSubview(imageView) root.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: root.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: root.trailingAnchor),
imageView.topAnchor.constraint(equalTo: root.topAnchor),
imageView.bottomAnchor.constraint(equalTo: root.bottomAnchor),
])
self.view = root self.view = root
load(into: imageView) primeImage()
} }
private func load(into imageView: UIImageView) { // MARK: - Image loading
guard let url = imageURL else { return }
// Use URLSession + URLCache so we cooperate with Expo Image's HTTP cache. private func primeImage() {
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 10) // 1. Fullsize cache hit? Paint it immediately.
if let cached = URLCache.shared.cachedResponse(for: request), if let url = imageURL, let cached = cachedImage(for: url) {
let image = UIImage(data: cached.data) { imageView.image = cached
imageView.image = image
return return
} }
URLSession.shared.dataTask(with: request) { [weak imageView] data, _, _ in // 2. Thumb placeholder (almost always cached by the feed).
guard let data = data, let image = UIImage(data: data) else { return } if let thumb = thumbURL, let cached = cachedImage(for: thumb) {
DispatchQueue.main.async { imageView?.image = image } imageView.image = cached
}.resume() }
// 3. Kick off the async fullsize load.
guard let url = imageURL else { return }
SDWebImageManager.shared.loadImage(
with: url,
options: [.retryFailed],
progress: nil
) { [weak self] image, _, _, _, _, _ in
guard let self = self, let image = image else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
}
}
/// Synchronous cache lookup across memory + disk. Memory hits are instant;
/// disk hits incur a small read but remain on-thread (matches what SDWebImage
/// does when the cache policy permits).
private func cachedImage(for url: URL) -> UIImage? {
let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString
if let memory = SDImageCache.shared.imageFromMemoryCache(forKey: key) {
return memory
}
return SDImageCache.shared.imageFromDiskCache(forKey: key)
} }
/// Caps the preview to a comfortable size within the current key window. /// Caps the preview to a comfortable size within the current key window.
@@ -11,9 +11,15 @@ enum PreviewFactory {
switch type { switch type {
case "image": case "image":
let uri = spec["uri"] as? String let uri = spec["uri"] as? String
let thumbUri = spec["thumbUri"] as? String
let url = uri.flatMap(URL.init(string:)) let url = uri.flatMap(URL.init(string:))
let thumbURL = thumbUri.flatMap(URL.init(string:))
let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1) let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1)
return ImagePreviewController(imageURL: url, aspectRatio: aspect) return ImagePreviewController(
imageURL: url,
thumbURL: thumbURL,
aspectRatio: aspect
)
default: default:
return nil return nil
} }
@@ -14,6 +14,10 @@ export type PreviewContent =
| { | {
type: 'image' type: 'image'
uri: string uri: string
/** Thumb URL. When present, the native side paints it in as an instant
* placeholder (reading from the shared SDWebImage cache) while the
* fullsize loads — avoids the black flash on first peek. */
thumbUri?: string
/** Aspect ratio as width / height. */ /** Aspect ratio as width / height. */
aspectRatio: number aspectRatio: number
} }
@@ -20,6 +20,7 @@ import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu'
*/ */
export function ImageContextMenu({ export function ImageContextMenu({
fullsizeUri, fullsizeUri,
thumbUri,
aspectRatio, aspectRatio,
borderRadius, borderRadius,
onPreviewPress, onPreviewPress,
@@ -27,6 +28,9 @@ export function ImageContextMenu({
children, children,
}: { }: {
fullsizeUri: string fullsizeUri: string
/** Thumbnail URL. Used as an instant placeholder in the native preview
* while the fullsize loads, so there's no black flash on first peek. */
thumbUri?: string
/** width / height; defaults to 1 if missing. */ /** width / height; defaults to 1 if missing. */
aspectRatio: number | undefined aspectRatio: number | undefined
borderRadius?: number borderRadius?: number
@@ -54,6 +58,7 @@ export function ImageContextMenu({
preview={{ preview={{
type: 'image', type: 'image',
uri: fullsizeUri, uri: fullsizeUri,
thumbUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1, aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}} }}
borderRadius={borderRadius} borderRadius={borderRadius}
+1
View File
@@ -80,6 +80,7 @@ export function ImageEmbed({
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<ImageContextMenu <ImageContextMenu
fullsizeUri={image.fullsize} fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect} aspectRatio={aspect}
borderRadius={tokens.borderRadius.md} borderRadius={tokens.borderRadius.md}
onPreviewPress={() => onPreviewPress(0)}> onPreviewPress={() => onPreviewPress(0)}>
+1
View File
@@ -426,6 +426,7 @@ function GalleryImage({
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<ImageContextMenu <ImageContextMenu
fullsizeUri={image.fullsize} fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
borderRadius={tokens.borderRadius.md} borderRadius={tokens.borderRadius.md}
onPreviewPress={onPreviewPress}> onPreviewPress={onPreviewPress}>
@@ -66,6 +66,7 @@ export function GalleryItem({
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<ImageContextMenu <ImageContextMenu
fullsizeUri={image.fullsize} fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect} aspectRatio={aspect}
onPreviewPress={ onPreviewPress={
onPreviewPress ? () => onPreviewPress(index) : undefined onPreviewPress ? () => onPreviewPress(index) : undefined