From 1a0dae77bfc39b67c6d4bc4a00565c03ca4b4fc6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 7 Nov 2026 15:40:05 -0800 Subject: [PATCH] fix(mobile): shimmer loading photos in the iOS carousel (#9167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- mobile/ios/Runner/IosMediaCarousel.swift | 86 ++++++++++++++++++- .../shared/widgets/ios_media_carousel.dart | 4 + 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/mobile/ios/Runner/IosMediaCarousel.swift b/mobile/ios/Runner/IosMediaCarousel.swift index 285a9edb32..b86731bdac 100644 --- a/mobile/ios/Runner/IosMediaCarousel.swift +++ b/mobile/ios/Runner/IosMediaCarousel.swift @@ -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) { diff --git a/mobile/lib/shared/widgets/ios_media_carousel.dart b/mobile/lib/shared/widgets/ios_media_carousel.dart index 1770c6de4d..42811ca409 100644 --- a/mobile/lib/shared/widgets/ios_media_carousel.dart +++ b/mobile/lib/shared/widgets/ios_media_carousel.dart @@ -43,6 +43,8 @@ class IosMediaCarousel extends HookWidget { final channel = useState(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 = { @@ -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, ],