Files
bsky-social-app/modules/expo-bluesky-context-menu/ios/ExpoBlueskyContextMenuView.swift
T
Claude 1856c36221 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
2026-05-25 14:35:14 +03:00

95 lines
3.4 KiB
Swift

import ExpoModulesCore
import UIKit
/// Native view that hosts the children and attaches a
/// `UIContextMenuInteraction`. JS-shipped props drive behaviour:
/// - `preview`: discriminated union describing what to show during peek
/// - `menuItems`: array of menu item specs (see `MenuBuilder`)
/// - `previewCornerRadius`: used for the targeted preview's visible path so the
/// lift animation matches the thumbnail's clipping. (Named distinctly from
/// the RN-owned `borderRadius` style prop on UIView.)
class ExpoBlueskyContextMenuView: ExpoView, UIContextMenuInteractionDelegate {
private var preview: [String: Any]?
private var menuItems: [[String: Any]] = []
private var previewCornerRadius: CGFloat = 0
private let onItemPress = EventDispatcher()
private let onPreviewPress = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let interaction = UIContextMenuInteraction(delegate: self)
self.addInteraction(interaction)
}
func setPreview(_ value: [String: Any]?) { self.preview = value }
func setMenuItems(_ value: [[String: Any]]) { self.menuItems = value }
func setPreviewCornerRadius(_ value: Double) {
self.previewCornerRadius = CGFloat(value)
}
// MARK: - UIContextMenuInteractionDelegate
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
let previewSpec = self.preview
let items = self.menuItems
return UIContextMenuConfiguration(
identifier: nil,
previewProvider: { [weak self] in
guard self != nil else { return nil }
return PreviewFactory.makeController(from: previewSpec)
},
actionProvider: { [weak self] _ in
guard let self = self else { return nil }
return MenuBuilder.build(items: items) { [weak self] id in
self?.onItemPress(["id": id])
}
}
)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
previewForHighlightingMenuWithConfiguration 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,
willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionCommitAnimating
) {
animator.addCompletion { [weak self] in
self?.onPreviewPress([:])
}
}
// MARK: - Targeted preview
/// The targeted preview uses the view itself as target with a rounded-corner
/// visible path matching the thumbnail's clipping, so the lift animation
/// respects the existing corner radius.
private func makeTargetedPreview() -> UITargetedPreview {
let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
if previewCornerRadius > 0 {
parameters.visiblePath = UIBezierPath(
roundedRect: self.bounds,
cornerRadius: previewCornerRadius
)
}
return UITargetedPreview(view: self, parameters: parameters)
}
}