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:
@@ -10,6 +10,8 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
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 = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
|
||||
@@ -59,12 +59,11 @@ class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate {
|
||||
return makeTargetedPreview()
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
||||
) -> UITargetedPreview? {
|
||||
return makeTargetedPreview()
|
||||
}
|
||||
// NOTE: Intentionally not implementing
|
||||
// `previewForDismissingMenuWithConfiguration`. iOS reverses the highlight
|
||||
// animation by default, which interpolates size + position together. Supplying
|
||||
// a separate dismiss preview tends to produce a two-stage animation on
|
||||
// aspect-mismatched thumbnails (snap to original size, then translate).
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SDWebImage
|
||||
import UIKit
|
||||
|
||||
/// 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 final size without the mid-flight stretch that happens when a mis-sized
|
||||
/// 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 {
|
||||
private let imageURL: URL?
|
||||
private let thumbURL: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
|
||||
init(imageURL: URL?, aspectRatio: CGFloat) {
|
||||
private let imageView = UIImageView()
|
||||
|
||||
init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) {
|
||||
self.imageURL = imageURL
|
||||
self.thumbURL = thumbURL
|
||||
self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.preferredContentSize = Self.sizeForAspect(self.aspectRatio)
|
||||
@@ -24,36 +38,54 @@ final class ImagePreviewController: UIViewController {
|
||||
root.backgroundColor = .black
|
||||
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.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.backgroundColor = .black
|
||||
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
|
||||
load(into: imageView)
|
||||
primeImage()
|
||||
}
|
||||
|
||||
private func load(into imageView: UIImageView) {
|
||||
guard let url = imageURL else { return }
|
||||
// Use URLSession + URLCache so we cooperate with Expo Image's HTTP cache.
|
||||
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 10)
|
||||
if let cached = URLCache.shared.cachedResponse(for: request),
|
||||
let image = UIImage(data: cached.data) {
|
||||
imageView.image = image
|
||||
// MARK: - Image loading
|
||||
|
||||
private func primeImage() {
|
||||
// 1. Fullsize cache hit? Paint it immediately.
|
||||
if let url = imageURL, let cached = cachedImage(for: url) {
|
||||
imageView.image = cached
|
||||
return
|
||||
}
|
||||
URLSession.shared.dataTask(with: request) { [weak imageView] data, _, _ in
|
||||
guard let data = data, let image = UIImage(data: data) else { return }
|
||||
DispatchQueue.main.async { imageView?.image = image }
|
||||
}.resume()
|
||||
// 2. Thumb placeholder (almost always cached by the feed).
|
||||
if let thumb = thumbURL, let cached = cachedImage(for: thumb) {
|
||||
imageView.image = cached
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -11,9 +11,15 @@ enum PreviewFactory {
|
||||
switch type {
|
||||
case "image":
|
||||
let uri = spec["uri"] as? String
|
||||
let thumbUri = spec["thumbUri"] as? String
|
||||
let url = uri.flatMap(URL.init(string:))
|
||||
let thumbURL = thumbUri.flatMap(URL.init(string:))
|
||||
let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1)
|
||||
return ImagePreviewController(imageURL: url, aspectRatio: aspect)
|
||||
return ImagePreviewController(
|
||||
imageURL: url,
|
||||
thumbURL: thumbURL,
|
||||
aspectRatio: aspect
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export type PreviewContent =
|
||||
| {
|
||||
type: 'image'
|
||||
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. */
|
||||
aspectRatio: number
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu'
|
||||
*/
|
||||
export function ImageContextMenu({
|
||||
fullsizeUri,
|
||||
thumbUri,
|
||||
aspectRatio,
|
||||
borderRadius,
|
||||
onPreviewPress,
|
||||
@@ -27,6 +28,9 @@ export function ImageContextMenu({
|
||||
children,
|
||||
}: {
|
||||
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. */
|
||||
aspectRatio: number | undefined
|
||||
borderRadius?: number
|
||||
@@ -54,6 +58,7 @@ export function ImageContextMenu({
|
||||
preview={{
|
||||
type: 'image',
|
||||
uri: fullsizeUri,
|
||||
thumbUri,
|
||||
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
|
||||
}}
|
||||
borderRadius={borderRadius}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function ImageEmbed({
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspect}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={() => onPreviewPress(0)}>
|
||||
|
||||
@@ -426,6 +426,7 @@ function GalleryImage({
|
||||
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspectRatio}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={onPreviewPress}>
|
||||
|
||||
@@ -66,6 +66,7 @@ export function GalleryItem({
|
||||
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspect}
|
||||
onPreviewPress={
|
||||
onPreviewPress ? () => onPreviewPress(index) : undefined
|
||||
|
||||
Reference in New Issue
Block a user