fix(mobile): shimmer loading photos in the iOS carousel (#9167)

On iOS, a message with several photos renders them in a native UIKit
carousel. While a thumbnail downloads, each cell showed the words
"Loading image". Single images and the Flutter carousel already show a
shimmering skeleton, so the native carousel stood out.

Native carousel cells now show the same shimmer as Flutter's
`SkeletonShimmer`: a fill in the theme's primary color at 10% opacity,
with a lighter band sweeping across every two seconds. Flutter passes
the primary color to the native view along with the brightness. With
Reduce Motion on, the fill stays still. Once the thumbnail arrives the
shimmer hides. If the thumbnail fails, the cell shows its existing
"Image unavailable" message on the plain surface.

### Demo

A synthetic private chat with a four-photo message, reopened on a cold
launch. To make the loading state easy to see, both builds were patched
locally to delay thumbnail delivery by 4 seconds (the patch is not part
of this PR).

Before (main): each cell shows the words "Loading image" until the
thumbnail arrives.


https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac

After (this PR): each cell shimmers in the theme's primary color until
the thumbnail arrives.


https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc

### Validation

- `just mobile-check` and the full `just mobile-test` suite pass.
- Automated simulator run (iPhone 17 Pro, debug build): posted four
photos to a synthetic private chat from the app's photo picker,
relaunched, and opened the chat on both main and this branch. Before
shows "Loading image"; after shows the shimmer, which gives way to the
photo when it arrives. Limitations: light mode only, and the 4 second
thumbnail delay was a local patch to make the state observable. Dark
mode, Reduce Motion and the failed-thumbnail state were not exercised.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-07 15:40:05 -08:00
committed by GitHub
parent f3b127e3af
commit 1a0dae77bf
2 changed files with 88 additions and 2 deletions

View File

@@ -12,9 +12,71 @@ final class IosMediaCarouselFactory: NSObject, FlutterPlatformViewFactory {
}
}
/// Mirrors Flutter's SkeletonShimmer: a translucent primary fill with a
/// lighter band sweeping across it every two seconds.
private final class CarouselShimmer: UIView {
private let gradient = CAGradientLayer()
var tint: UIColor = .systemBlue {
didSet { updateColors() }
}
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
gradient.locations = [0, 0.34, 0.5, 0.66, 1]
layer.addSublayer(gradient)
updateColors()
NotificationCenter.default.addObserver(
self,
selector: #selector(reduceMotionChanged),
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil
)
}
@objc private func reduceMotionChanged() { animate() }
required init?(coder: NSCoder) { nil }
override func layoutSubviews() {
super.layoutSubviews()
gradient.frame = bounds
}
override func didMoveToWindow() {
super.didMoveToWindow()
animate()
}
override var isHidden: Bool {
didSet { animate() }
}
private func updateColors() {
let base = tint.withAlphaComponent(0.1).cgColor
let highlight = tint.withAlphaComponent(0.05).cgColor
gradient.colors = [base, base, highlight, base, base]
}
private func animate() {
gradient.removeAllAnimations()
// The gradient spans the cell's width; its center travels from 0.75
// widths before the cell to 0.75 widths past it, like the Flutter sweep.
gradient.startPoint = CGPoint(x: -0.75, y: 0.5)
gradient.endPoint = CGPoint(x: 0.25, y: 0.5)
guard window != nil, !isHidden, !UIAccessibility.isReduceMotionEnabled else { return }
let start = CABasicAnimation(keyPath: "startPoint")
start.fromValue = CGPoint(x: -0.75, y: 0.5)
start.toValue = CGPoint(x: 0.75, y: 0.5)
let end = CABasicAnimation(keyPath: "endPoint")
end.fromValue = CGPoint(x: 0.25, y: 0.5)
end.toValue = CGPoint(x: 1.75, y: 0.5)
let sweep = CAAnimationGroup()
sweep.animations = [start, end]
sweep.duration = 2
sweep.repeatCount = .infinity
sweep.timingFunction = CAMediaTimingFunction(name: .linear)
sweep.isRemovedOnCompletion = false
gradient.add(sweep, forKey: "shimmer")
}
}
private final class CarouselPhotoCell: UICollectionViewCell {
let image = UIImageView()
let status = UILabel()
let shimmer = CarouselShimmer()
var request: String?
var cancelRequest: (() -> Void)?
func cancelImage() {
@@ -32,6 +94,7 @@ private final class CarouselPhotoCell: UICollectionViewCell {
status.numberOfLines = 2
status.font = .preferredFont(forTextStyle: .caption1)
status.textColor = .secondaryLabel
contentView.addSubview(shimmer)
contentView.addSubview(image)
contentView.addSubview(status)
isAccessibilityElement = true
@@ -41,6 +104,7 @@ private final class CarouselPhotoCell: UICollectionViewCell {
override func layoutSubviews() {
super.layoutSubviews()
image.frame = contentView.bounds
shimmer.frame = contentView.bounds
status.frame = contentView.bounds.insetBy(dx: 12, dy: 12)
}
override func prepareForReuse() {
@@ -49,6 +113,12 @@ private final class CarouselPhotoCell: UICollectionViewCell {
image.image = nil
status.text = nil
}
/// Shows the shimmer until the thumbnail resolves; a failed thumbnail gets
/// the plain surface behind its message instead.
func showLoading(_ loading: Bool) {
shimmer.isHidden = !loading
contentView.backgroundColor = loading ? .clear : .secondarySystemBackground
}
}
/// A native collection view owns drag tracking, deceleration and snapping.
@@ -63,6 +133,7 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
private var gap: CGFloat = 8
private var trailingPadding: CGFloat = 16
private var radius: CGFloat = 16
private var skeletonTint: UIColor = .systemBlue
private var rtl = false
private var ready = false
private var applying = false
@@ -89,6 +160,7 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
for case let cell as CarouselPhotoCell in self.collection.visibleCells where cell.request == id {
let image = (args["bytes"] as? FlutterStandardTypedData).flatMap { UIImage(data: $0.data) }
cell.image.image = image
cell.showLoading(false)
cell.status.text = image == nil ? "Image unavailable\nTap to open" : nil
}
result(nil)
@@ -121,6 +193,14 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
gap = args["gap"] as? Double ?? 8
trailingPadding = args["trailingPadding"] as? Double ?? 16
radius = args["radius"] as? Double ?? 16
if let argb = args["skeletonColor"] as? Int {
skeletonTint = UIColor(
red: CGFloat((argb >> 16) & 0xFF) / 255,
green: CGFloat((argb >> 8) & 0xFF) / 255,
blue: CGFloat(argb & 0xFF) / 255,
alpha: 1
)
}
rtl = args["rtl"] as? Bool ?? false
collection.overrideUserInterfaceStyle = args["brightness"] as? String == "dark" ? .dark : .light
// Reflect the strip and each cell, keeping logical indices in reading order.
@@ -130,8 +210,9 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
// The strip overflows the message gutter; restore that full inset at its end.
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: trailingPadding)
layout.invalidateLayout()
for cell in collection.visibleCells {
for case let cell as CarouselPhotoCell in collection.visibleCells {
cell.contentView.layer.cornerRadius = radius
cell.shimmer.tint = skeletonTint
}
if oldTrailingPadding != trailingPadding || oldStride != stride || oldGutter != gutter || oldLabels != labels || oldRTL != rtl {
selected = min(selected, max(0, labels.count - 1))
@@ -156,7 +237,8 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
cell.contentView.layer.cornerRadius = radius
cell.accessibilityLabel = "Open \(labels[indexPath.item])"
cell.accessibilityValue = "\(indexPath.item + 1) of \(labels.count)"
cell.status.text = "Loading image"
cell.shimmer.tint = skeletonTint
cell.showLoading(true)
return cell
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {

View File

@@ -43,6 +43,8 @@ class IosMediaCarousel extends HookWidget {
final channel = useState<MethodChannel?>(null);
final latest = useRef(this)..value = this;
final brightness = Theme.of(context).brightness.name;
// Tints UIKit's loading shimmer like Flutter's SkeletonShimmer.
final skeletonColor = Theme.of(context).colorScheme.primary.toARGB32();
final labelSignature = Object.hashAll(labels);
final rtl = Directionality.of(context) == TextDirection.rtl;
final configuration = <String, Object>{
@@ -53,6 +55,7 @@ class IosMediaCarousel extends HookWidget {
'trailingPadding': trailingPadding,
'radius': radius,
'brightness': brightness,
'skeletonColor': skeletonColor,
'rtl': rtl,
};
useEffect(() {
@@ -109,6 +112,7 @@ class IosMediaCarousel extends HookWidget {
trailingPadding,
radius,
brightness,
skeletonColor,
rtl,
labelSignature,
],