mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`. On iOS, inline photo galleries now use a UIKit collection view for dragging, deceleration, and snapping. Photo taps retain the existing full-screen viewer and reply/more actions; gallery scrolling continues to fade the message avatar. The final photo stops at the full message gutter rather than the smaller gap between photos. Android keeps the current Flutter carousel. Visible cells subscribe to continuing frames from Flutter's existing authenticated image providers, preserving animated GIF/APNG/WebP media. Request IDs fence late frames and cancel subscriptions on reuse/disappearance/disposal. A four-slot bound includes retired unresolved loads until their transport terminates. Shared media fetches now use abortable HTTP requests with a 30-second deadline through response-body completion, so stalled requests release their slots without leaving live transports behind; native eager precaching is disabled so it cannot bypass that bound. Frame conversion coalesces to the newest pending frame. Native cells expose photo labels and positions to VoiceOver, support RTL, and preserve the selected photo during resizing. Validation: - Mobile formatting and analysis passed. - Native simulator regression checks passed for geometry, RTL, snapping, taps, frame replacement, cancellation, and resizing. The native CI lane now runs the carousel executable. - Full mobile suite and pre-push gates passed, including two-frame GIF delivery, delayed-provider bounds/cancellation, real HTTP stalled-header/body recovery with four requests maximum, disposal, and unconfigured-push tests. - Signed iPhone Release build of the frame/subscription implementation passed. Physical iPhone testing covered the earlier carousel/gutter revision; the new animation/cancellation changes have local Flutter and native simulator coverage, not a new physical-device journey. Preview-only signing overrides preserve its bundle/Keychain group; production entitlements are unchanged. Local provisioning excludes production push/App Attest capabilities. - Full `just ci` was started but stopped after unrelated Rust/desktop compilation consumed about 5 GB; broad CI is incomplete. Device build required local Xcode 27 workarounds: iOS 16 deployment target and a generated swift-secp256k1 cache compatibility edit. Neither changes repository source. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -206,6 +206,7 @@ jobs:
|
||||
xcrun simctl boot "$device"
|
||||
xcrun simctl bootstatus "$device" -b
|
||||
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
|
||||
/bin/bash scripts/test-mobile-carousel.sh "$device"
|
||||
/bin/bash scripts/test-mobile-member-picker.sh "$device"
|
||||
xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \
|
||||
-configuration Debug -destination "platform=iOS Simulator,id=$device" \
|
||||
|
||||
125
mobile/ios/NativeControlTests/IosMediaCarouselTests.swift
Normal file
125
mobile/ios/NativeControlTests/IosMediaCarouselTests.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct IosMediaCarouselTests {
|
||||
@MainActor static func main() {
|
||||
let messenger = CarouselTestMessenger()
|
||||
var args: [String: Any] = [
|
||||
"labels": ["Photo A", "Photo B", "Photo C"],
|
||||
"width": 300.0, "gutter": 20.0, "gap": 4.0, "trailingPadding": 20.0, "radius": 16.0,
|
||||
]
|
||||
let platform = IosMediaCarousel(
|
||||
frame: CGRect(x: 0, y: 0, width: 340, height: 240), id: 42,
|
||||
arguments: args, messenger: messenger
|
||||
)
|
||||
guard let strip = platform.view() as? UICollectionView else {
|
||||
preconditionFailure("Carousel must use native collection scrolling")
|
||||
}
|
||||
messenger.configure(args)
|
||||
strip.layoutIfNeeded()
|
||||
precondition(strip.numberOfItems(inSection: 0) == 3)
|
||||
precondition(strip.contentOffset.x == -20, "Initial photo starts at gutter")
|
||||
precondition(strip.decelerationRate == .fast)
|
||||
let first = IndexPath(item: 0, section: 0)
|
||||
guard let cell = strip.cellForItem(at: first) else {
|
||||
preconditionFailure("Missing photo cell")
|
||||
}
|
||||
precondition(cell.accessibilityLabel == "Open Photo A")
|
||||
precondition(cell.accessibilityTraits.contains(.button))
|
||||
precondition(messenger.calls.contains { $0.method == "subscribeImage" }, "Images requested after ready")
|
||||
guard let subscription = messenger.calls.first(where: {
|
||||
$0.method == "subscribeImage" && ($0.arguments as? [String: Any])?["index"] as? Int == 0
|
||||
}), let id = (subscription.arguments as? [String: Any])?["id"] as? String,
|
||||
let photo = cell.contentView.subviews.compactMap({ $0 as? UIImageView }).first else {
|
||||
preconditionFailure("Missing visible photo subscription")
|
||||
}
|
||||
func frame(_ color: UIColor) -> Data {
|
||||
UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in
|
||||
color.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
|
||||
}
|
||||
}
|
||||
messenger.frame(id, frame(.red))
|
||||
let firstFrame = photo.image?.pngData()
|
||||
messenger.frame(id, frame(.blue))
|
||||
precondition(firstFrame != nil && photo.image?.pngData() != firstFrame,
|
||||
"Native photo must display continuing animation frames")
|
||||
precondition(subscription.arguments is [String: Any], "Subscriptions carry request IDs")
|
||||
platform.collectionView(strip, didEndDisplaying: cell, forItemAt: first)
|
||||
precondition(messenger.calls.last?.method == "cancelImage", "Disappearing cells cancel subscriptions")
|
||||
let stoppedFrame = photo.image?.pngData()
|
||||
messenger.frame(id, frame(.green))
|
||||
precondition(photo.image?.pngData() == stoppedFrame, "Late cancelled frames must not paint")
|
||||
platform.collectionView(strip, willDisplay: cell, forItemAt: first)
|
||||
let resubscription = messenger.calls.last
|
||||
cell.prepareForReuse()
|
||||
precondition(messenger.calls.last?.method == "cancelImage", "Reuse cancels its previous subscription")
|
||||
precondition((resubscription?.arguments as? [String: Any])?["id"] as? String == messenger.calls.last?.arguments as? String)
|
||||
platform.collectionView(strip, didSelectItemAt: IndexPath(item: 1, section: 0))
|
||||
precondition(messenger.calls.last?.method == "open")
|
||||
precondition(messenger.calls.last?.arguments as? Int == 1)
|
||||
var target = CGPoint(x: 235, y: 0)
|
||||
platform.scrollViewWillEndDragging(strip, withVelocity: CGPoint(x: 1, y: 0), targetContentOffset: &target)
|
||||
precondition(target.x == 250, "Drag snaps to native item stride")
|
||||
target.x = 10000
|
||||
platform.scrollViewWillEndDragging(strip, withVelocity: .zero, targetContentOffset: &target)
|
||||
precondition(target.x <= strip.contentSize.width - strip.bounds.width + 20, "No trailing blank page")
|
||||
let last = IndexPath(item: 2, section: 0)
|
||||
guard let lastFrame = strip.layoutAttributesForItem(at: last)?.frame else {
|
||||
preconditionFailure("Missing final photo layout")
|
||||
}
|
||||
let trailingEdge = lastFrame.maxX - target.x
|
||||
precondition(abs((strip.bounds.width - 20) - trailingEdge - 20) < 0.01,
|
||||
"Final photo must stop at the full 20pt message gutter, not the 4pt photo gap")
|
||||
precondition(lastFrame.minX == 540, "Trailing padding must not change page stride")
|
||||
strip.contentOffset.x = 250
|
||||
platform.scrollViewDidScroll(strip)
|
||||
precondition(messenger.calls.contains { $0.method == "page" && $0.arguments as? Int == 1 })
|
||||
args["rtl"] = true
|
||||
messenger.configure(args)
|
||||
precondition(strip.transform.a == -1, "Native strip follows RTL reading order")
|
||||
strip.layoutIfNeeded()
|
||||
let rtlCell = strip.visibleCells.first
|
||||
precondition(rtlCell?.transform.a == -1, "Photo itself is not mirrored")
|
||||
target.x = 10000
|
||||
platform.scrollViewWillEndDragging(strip, withVelocity: .zero, targetContentOffset: &target)
|
||||
guard let rtlLast = strip.layoutAttributesForItem(at: last)?.frame else {
|
||||
preconditionFailure("Missing RTL final photo layout")
|
||||
}
|
||||
precondition(abs((strip.bounds.width - 20) - (rtlLast.maxX - target.x) - 20) < 0.01,
|
||||
"RTL final photo must retain the same message gutter")
|
||||
args["width"] = 200.0
|
||||
messenger.configure(args)
|
||||
precondition(strip.contentOffset.x == 160, "Resize retains the selected logical photo")
|
||||
print("Native carousel scrolling, bounds, RTL, image requests, and taps passed")
|
||||
}
|
||||
}
|
||||
|
||||
private final class CarouselTestMessenger: NSObject, FlutterBinaryMessenger {
|
||||
var calls: [FlutterMethodCall] = []
|
||||
var handler: FlutterBinaryMessageHandler?
|
||||
func frame(_ id: String, _ data: Data) {
|
||||
let codec = FlutterStandardMethodCodec.sharedInstance()
|
||||
handler?(codec.encode(FlutterMethodCall(methodName: "imageFrame", arguments: [
|
||||
"id": id, "bytes": FlutterStandardTypedData(bytes: data)
|
||||
])), { _ in })
|
||||
}
|
||||
func configure(_ args: [String: Any]) {
|
||||
let codec = FlutterStandardMethodCodec.sharedInstance()
|
||||
handler?(codec.encode(FlutterMethodCall(methodName: "configure", arguments: args)), { _ in })
|
||||
}
|
||||
func send(onChannel channel: String, message: Data?) {
|
||||
guard let message else { return }
|
||||
calls.append(FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message))
|
||||
}
|
||||
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
|
||||
send(onChannel: channel, message: message)
|
||||
callback?(FlutterStandardMethodCodec.sharedInstance().encodeSuccessEnvelope(nil))
|
||||
}
|
||||
func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection {
|
||||
self.handler = handler
|
||||
return 1
|
||||
}
|
||||
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {}
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
|
||||
CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8300022F50000100A17E01 /* IosMediaCarousel.swift */; };
|
||||
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */; };
|
||||
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; };
|
||||
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; };
|
||||
@@ -121,6 +122,7 @@
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = "<group>"; };
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = "<group>"; };
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = "<group>"; };
|
||||
CA8300022F50000100A17E01 /* IosMediaCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosMediaCarousel.swift; sourceTree = "<group>"; };
|
||||
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGeometry.swift; sourceTree = "<group>"; };
|
||||
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = "<group>"; };
|
||||
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = "<group>"; };
|
||||
@@ -291,6 +293,7 @@
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
|
||||
CA8300022F50000100A17E01 /* IosMediaCarousel.swift */,
|
||||
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */,
|
||||
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */,
|
||||
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */,
|
||||
@@ -627,6 +630,7 @@
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
|
||||
CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */,
|
||||
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */,
|
||||
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */,
|
||||
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */,
|
||||
|
||||
@@ -59,6 +59,10 @@ import os.log
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosMediaCarousel") {
|
||||
registrar.register(IosMediaCarouselFactory(messenger: registrar.messenger()), withId: "buzz/ios_media_carousel")
|
||||
}
|
||||
|
||||
let messenger = engineBridge.applicationRegistrar.messenger()
|
||||
huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger)
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosNavigationBar") {
|
||||
|
||||
202
mobile/ios/Runner/IosMediaCarousel.swift
Normal file
202
mobile/ios/Runner/IosMediaCarousel.swift
Normal file
@@ -0,0 +1,202 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class IosMediaCarouselFactory: NSObject, FlutterPlatformViewFactory {
|
||||
private let messenger: FlutterBinaryMessenger
|
||||
init(messenger: FlutterBinaryMessenger) { self.messenger = messenger }
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
|
||||
IosMediaCarousel(frame: frame, id: viewId, arguments: args, messenger: messenger)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CarouselPhotoCell: UICollectionViewCell {
|
||||
let image = UIImageView()
|
||||
let status = UILabel()
|
||||
var request: String?
|
||||
var cancelRequest: (() -> Void)?
|
||||
func cancelImage() {
|
||||
cancelRequest?()
|
||||
cancelRequest = nil
|
||||
request = nil
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
image.contentMode = .scaleAspectFill
|
||||
contentView.clipsToBounds = true
|
||||
contentView.layer.cornerCurve = .continuous
|
||||
contentView.backgroundColor = .secondarySystemBackground
|
||||
status.textAlignment = .center
|
||||
status.numberOfLines = 2
|
||||
status.font = .preferredFont(forTextStyle: .caption1)
|
||||
status.textColor = .secondaryLabel
|
||||
contentView.addSubview(image)
|
||||
contentView.addSubview(status)
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = .button
|
||||
}
|
||||
required init?(coder: NSCoder) { nil }
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
image.frame = contentView.bounds
|
||||
status.frame = contentView.bounds.insetBy(dx: 12, dy: 12)
|
||||
}
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
cancelImage()
|
||||
image.image = nil
|
||||
status.text = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A native collection view owns drag tracking, deceleration and snapping.
|
||||
/// Thumbnail bytes come from Flutter's authenticated, community-scoped cache.
|
||||
final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
private let collection: UICollectionView
|
||||
private let layout = UICollectionViewFlowLayout()
|
||||
private let channel: FlutterMethodChannel
|
||||
private var labels: [String] = []
|
||||
private var stride: CGFloat = 1
|
||||
private var gutter: CGFloat = 0
|
||||
private var gap: CGFloat = 8
|
||||
private var trailingPadding: CGFloat = 16
|
||||
private var radius: CGFloat = 16
|
||||
private var rtl = false
|
||||
private var ready = false
|
||||
private var applying = false
|
||||
private var selected = 0
|
||||
|
||||
init(frame: CGRect, id: Int64, arguments: Any?, messenger: FlutterBinaryMessenger) {
|
||||
layout.scrollDirection = .horizontal
|
||||
collection = UICollectionView(frame: frame, collectionViewLayout: layout)
|
||||
channel = FlutterMethodChannel(name: "buzz/ios_media_carousel/\(id)", binaryMessenger: messenger)
|
||||
super.init()
|
||||
collection.backgroundColor = .clear
|
||||
collection.showsHorizontalScrollIndicator = false
|
||||
collection.alwaysBounceHorizontal = true
|
||||
collection.decelerationRate = .fast
|
||||
collection.contentInsetAdjustmentBehavior = .never
|
||||
collection.dataSource = self
|
||||
collection.delegate = self
|
||||
collection.register(CarouselPhotoCell.self, forCellWithReuseIdentifier: "photo")
|
||||
apply(arguments as? [String: Any] ?? [:])
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else { result(nil); return }
|
||||
if call.method == "imageFrame", let args = call.arguments as? [String: Any],
|
||||
let id = args["id"] as? String {
|
||||
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.status.text = image == nil ? "Image unavailable\nTap to open" : nil
|
||||
}
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
guard call.method == "configure", let args = call.arguments as? [String: Any] else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
let wasReady = self.ready
|
||||
self.ready = true
|
||||
self.apply(args)
|
||||
if !wasReady { self.collection.reloadData() }
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
func view() -> UIView { collection }
|
||||
|
||||
private func apply(_ args: [String: Any]) {
|
||||
applying = true
|
||||
defer { applying = false }
|
||||
let oldTrailingPadding = trailingPadding
|
||||
let oldStride = stride
|
||||
let oldGutter = gutter
|
||||
let oldRTL = rtl
|
||||
let oldLabels = labels
|
||||
labels = args["labels"] as? [String] ?? []
|
||||
stride = max(1, (args["width"] as? Double ?? 1) * 0.9)
|
||||
gutter = args["gutter"] as? Double ?? 0
|
||||
gap = args["gap"] as? Double ?? 8
|
||||
trailingPadding = args["trailingPadding"] as? Double ?? 16
|
||||
radius = args["radius"] as? Double ?? 16
|
||||
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.
|
||||
collection.transform = rtl ? CGAffineTransform(scaleX: -1, y: 1) : .identity
|
||||
collection.contentInset = UIEdgeInsets(top: 0, left: gutter, bottom: 0, right: gutter)
|
||||
layout.minimumLineSpacing = gap
|
||||
// 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 {
|
||||
cell.contentView.layer.cornerRadius = radius
|
||||
}
|
||||
if oldTrailingPadding != trailingPadding || oldStride != stride || oldGutter != gutter || oldLabels != labels || oldRTL != rtl {
|
||||
selected = min(selected, max(0, labels.count - 1))
|
||||
for case let cell as CarouselPhotoCell in collection.visibleCells { cell.cancelImage() }
|
||||
collection.reloadData()
|
||||
collection.layoutIfNeeded()
|
||||
collection.contentOffset.x = clampedOffset(CGFloat(selected) * stride - gutter)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
labels.count
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
CGSize(width: max(1, stride - gap), height: max(1, collectionView.bounds.height))
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photo", for: indexPath) as? CarouselPhotoCell else {
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
cell.transform = rtl ? CGAffineTransform(scaleX: -1, y: 1) : .identity
|
||||
cell.contentView.layer.cornerRadius = radius
|
||||
cell.accessibilityLabel = "Open \(labels[indexPath.item])"
|
||||
cell.accessibilityValue = "\(indexPath.item + 1) of \(labels.count)"
|
||||
cell.status.text = "Loading image"
|
||||
return cell
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
guard ready, let cell = cell as? CarouselPhotoCell else { return }
|
||||
cell.cancelImage()
|
||||
let id = UUID().uuidString
|
||||
cell.request = id
|
||||
cell.cancelRequest = { [weak self] in
|
||||
self?.channel.invokeMethod("cancelImage", arguments: id)
|
||||
}
|
||||
channel.invokeMethod("subscribeImage", arguments: ["id": id, "index": indexPath.item])
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
(cell as? CarouselPhotoCell)?.cancelImage()
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
channel.invokeMethod("open", arguments: indexPath.item)
|
||||
}
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard ready, !applying, !labels.isEmpty else { return }
|
||||
let offset = max(0, scrollView.contentOffset.x + gutter)
|
||||
channel.invokeMethod("scroll", arguments: offset)
|
||||
let index = min(labels.count - 1, max(0, Int((offset / stride).rounded())))
|
||||
if index != selected {
|
||||
selected = index
|
||||
channel.invokeMethod("page", arguments: index)
|
||||
}
|
||||
}
|
||||
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||
let page = ((targetContentOffset.pointee.x + gutter) / stride).rounded()
|
||||
targetContentOffset.pointee.x = clampedOffset(page * stride - gutter)
|
||||
}
|
||||
private func clampedOffset(_ offset: CGFloat) -> CGFloat {
|
||||
let maximum = max(-gutter, collection.contentSize.width - collection.bounds.width + gutter)
|
||||
return min(maximum, max(-gutter, offset))
|
||||
}
|
||||
deinit {
|
||||
for case let cell as CarouselPhotoCell in collection.visibleCells {
|
||||
if let id = cell.request { channel.invokeMethod("cancelImage", arguments: id) }
|
||||
}
|
||||
channel.setMethodCallHandler(nil)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import '../../shared/widgets/ios_media_carousel.dart';
|
||||
import '../../shared/widgets/message_attachment_corners.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@@ -103,98 +103,147 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
preloadProvider: previewProviders[index],
|
||||
),
|
||||
];
|
||||
final nativeGutter = math.max(
|
||||
0.0,
|
||||
MediaQuery.sizeOf(context).width - carouselWidth,
|
||||
);
|
||||
final carousel = SizedBox(
|
||||
key: const ValueKey('message-media-carousel'),
|
||||
width: carouselWidth,
|
||||
height: messageMediaCarouselHeight,
|
||||
child: _MessageCarouselViewport(
|
||||
width: carouselWidth,
|
||||
currentIndex: currentIndex.value,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
key: ValueKey('message-media-carousel-page:${item.url}'),
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: itemTrailingPaddings[index],
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'Open ${item.semanticLabel}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey('message-media-carousel-item:${item.url}'),
|
||||
onTap: () => openImageViewer(
|
||||
child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
|
||||
? OverflowBox(
|
||||
minWidth: carouselWidth + 2 * nativeGutter,
|
||||
maxWidth: carouselWidth + 2 * nativeGutter,
|
||||
child: IosMediaCarousel(
|
||||
key: ValueKey((itemSignature, mediaAuth)),
|
||||
providers: previewProviders,
|
||||
labels: [for (final item in items) item.semanticLabel],
|
||||
width: carouselWidth,
|
||||
gutter: nativeGutter,
|
||||
gap: Grid.half,
|
||||
trailingPadding: Grid.gutter,
|
||||
radius:
|
||||
(MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md))
|
||||
.topLeft
|
||||
.x,
|
||||
onScroll: (offset) => MessageGalleryScrollNotification(
|
||||
offset,
|
||||
).dispatch(context),
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
onOpen: (index) => openImageViewer(
|
||||
context,
|
||||
imageUrl: item.url,
|
||||
imageUrl: items[index].url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: item.semanticLabel,
|
||||
semanticLabel: items[index].semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: item.aspectRatio,
|
||||
aspectRatio: items[index].aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _MessageCarouselViewport(
|
||||
width: carouselWidth,
|
||||
currentIndex: currentIndex.value,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-page:${item.url}',
|
||||
),
|
||||
child: MediaViewerHero(
|
||||
tag: heroTags[index],
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
child: MediaImage(
|
||||
url: item.url,
|
||||
decodeWidth: previewDecodeWidths[index],
|
||||
fit: BoxFit.cover,
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: itemTrailingPaddings[index],
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'Open ${item.semanticLabel}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-item:${item.url}',
|
||||
),
|
||||
onTap: () => openImageViewer(
|
||||
context,
|
||||
imageUrl: item.url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: item.semanticLabel,
|
||||
frameBuilder:
|
||||
(context, child, frame, synchronous) =>
|
||||
frame != null || synchronous
|
||||
? child
|
||||
: const MediaLoadingPlaceholder(
|
||||
label: 'Loading image',
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: item.aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: MediaViewerHero(
|
||||
tag: heroTags[index],
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
child: MediaImage(
|
||||
url: item.url,
|
||||
decodeWidth: previewDecodeWidths[index],
|
||||
fit: BoxFit.cover,
|
||||
semanticLabel: item.semanticLabel,
|
||||
frameBuilder:
|
||||
(context, child, frame, synchronous) =>
|
||||
frame != null || synchronous
|
||||
? child
|
||||
: const MediaLoadingPlaceholder(
|
||||
label: 'Loading image',
|
||||
),
|
||||
errorBuilder: (_, _, _) =>
|
||||
const _MediaPreviewFallback(
|
||||
icon: BuzzIcons.imageOff,
|
||||
label: 'Image unavailable',
|
||||
),
|
||||
errorBuilder: (_, _, _) =>
|
||||
const _MediaPreviewFallback(
|
||||
icon: BuzzIcons.imageOff,
|
||||
label: 'Image unavailable',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final carouselSurface = Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
carousel,
|
||||
_MessageGalleryPrecache(
|
||||
providers: previewProviders,
|
||||
focusedIndex: currentIndex.value,
|
||||
),
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS)
|
||||
_MessageGalleryPrecache(
|
||||
providers: previewProviders,
|
||||
focusedIndex: currentIndex.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -45,12 +45,17 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
/// Excluded from equality: transport, not identity.
|
||||
final http.Client client;
|
||||
|
||||
/// Deadline for the complete response, including a stalled response body.
|
||||
/// Aborts the request without closing the shared client.
|
||||
final Duration requestTimeout;
|
||||
|
||||
const MediaImageProvider({
|
||||
required this.url,
|
||||
required this.auth,
|
||||
required this.client,
|
||||
this.onBytesLoaded,
|
||||
this.scale = 1.0,
|
||||
this.requestTimeout = const Duration(seconds: 30),
|
||||
});
|
||||
|
||||
static const _defaultCooldown = Duration(seconds: 30);
|
||||
@@ -97,7 +102,18 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
final uri = Uri.parse(url);
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await client.get(uri, headers: auth.headersFor(url));
|
||||
final abort = Completer<void>();
|
||||
final deadline = Timer(requestTimeout, abort.complete);
|
||||
try {
|
||||
final request = http.AbortableRequest(
|
||||
'GET',
|
||||
uri,
|
||||
abortTrigger: abort.future,
|
||||
)..headers.addAll(auth.headersFor(url));
|
||||
response = await http.Response.fromStream(await client.send(request));
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
}
|
||||
} catch (_) {
|
||||
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
||||
rethrow;
|
||||
|
||||
279
mobile/lib/shared/widgets/ios_media_carousel.dart
Normal file
279
mobile/lib/shared/widgets/ios_media_carousel.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// UIKit's horizontally scrolling photo strip, using the app's image providers.
|
||||
/// Authentication and image-cache isolation remain owned by Flutter.
|
||||
class IosMediaCarousel extends HookWidget {
|
||||
const IosMediaCarousel({
|
||||
super.key,
|
||||
required this.providers,
|
||||
required this.labels,
|
||||
required this.width,
|
||||
required this.gutter,
|
||||
required this.gap,
|
||||
required this.trailingPadding,
|
||||
required this.radius,
|
||||
required this.onOpen,
|
||||
required this.onScroll,
|
||||
required this.onPageChanged,
|
||||
});
|
||||
|
||||
static const viewType = 'buzz/ios_media_carousel';
|
||||
final List<ImageProvider<Object>> providers;
|
||||
final List<String> labels;
|
||||
final double width;
|
||||
final double gutter;
|
||||
final double gap;
|
||||
final double trailingPadding;
|
||||
final double radius;
|
||||
final ValueChanged<int> onOpen;
|
||||
final ValueChanged<double> onScroll;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channel = useState<MethodChannel?>(null);
|
||||
final latest = useRef(this)..value = this;
|
||||
final brightness = Theme.of(context).brightness.name;
|
||||
final labelSignature = Object.hashAll(labels);
|
||||
final rtl = Directionality.of(context) == TextDirection.rtl;
|
||||
final configuration = <String, Object>{
|
||||
'labels': labels,
|
||||
'width': width,
|
||||
'gutter': gutter,
|
||||
'gap': gap,
|
||||
'trailingPadding': trailingPadding,
|
||||
'radius': radius,
|
||||
'brightness': brightness,
|
||||
'rtl': rtl,
|
||||
};
|
||||
useEffect(() {
|
||||
final bridge = channel.value;
|
||||
if (bridge == null) return null;
|
||||
final images = _CarouselImages(bridge);
|
||||
channel.value!.setMethodCallHandler((call) async {
|
||||
final widget = latest.value;
|
||||
final argument = call.arguments;
|
||||
if (call.method == 'cancelImage' && argument is String) {
|
||||
images.cancel(argument);
|
||||
} else if (call.method == 'subscribeImage' && argument is Map) {
|
||||
final id = argument['id'];
|
||||
final index = argument['index'];
|
||||
if (id is String &&
|
||||
index is int &&
|
||||
index >= 0 &&
|
||||
index < widget.providers.length) {
|
||||
images.subscribe(
|
||||
id,
|
||||
widget.providers[index],
|
||||
createLocalImageConfiguration(context),
|
||||
);
|
||||
}
|
||||
} else if (call.method == 'scroll' && argument is num) {
|
||||
widget.onScroll(argument.toDouble());
|
||||
} else if (argument is int &&
|
||||
argument >= 0 &&
|
||||
argument < widget.providers.length) {
|
||||
if (call.method == 'open') widget.onOpen(argument);
|
||||
if (call.method == 'page') widget.onPageChanged(argument);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// UIKit requests visible thumbnails only after the Dart handler exists.
|
||||
unawaited(bridge.invokeMethod<void>('configure', configuration));
|
||||
return () {
|
||||
images.dispose();
|
||||
bridge.setMethodCallHandler(null);
|
||||
};
|
||||
}, [channel.value]);
|
||||
useEffect(
|
||||
() {
|
||||
final bridge = channel.value;
|
||||
if (bridge != null) {
|
||||
unawaited(bridge.invokeMethod<void>('configure', configuration));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[
|
||||
width,
|
||||
gutter,
|
||||
gap,
|
||||
trailingPadding,
|
||||
radius,
|
||||
brightness,
|
||||
rtl,
|
||||
labelSignature,
|
||||
],
|
||||
);
|
||||
|
||||
return UiKitView(
|
||||
viewType: viewType,
|
||||
creationParams: configuration,
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
gestureRecognizers: {
|
||||
Factory<HorizontalDragGestureRecognizer>(
|
||||
HorizontalDragGestureRecognizer.new,
|
||||
),
|
||||
},
|
||||
onPlatformViewCreated: (id) {
|
||||
channel.value = MethodChannel('$viewType/$id');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep native subscriptions and unresolved provider loads bounded together.
|
||||
// A cancelled load keeps its slot until its first frame/error. MediaImageProvider
|
||||
// aborts stalled HTTP requests at its deadline, allowing queued cells to progress
|
||||
// without releasing slots while their transports are still running.
|
||||
class _CarouselImages {
|
||||
_CarouselImages(this.channel);
|
||||
final MethodChannel channel;
|
||||
final _queued = <String, _CarouselImage>{};
|
||||
final _running = <String, _CarouselImage>{};
|
||||
bool _disposed = false;
|
||||
bool _draining = false;
|
||||
|
||||
void subscribe(
|
||||
String id,
|
||||
ImageProvider<Object> provider,
|
||||
ImageConfiguration config,
|
||||
) {
|
||||
if (_disposed || _running.containsKey(id) || _queued.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
_queued[id] = _CarouselImage(id, provider, config);
|
||||
_drain();
|
||||
}
|
||||
|
||||
void cancel(String id) {
|
||||
_queued.remove(id);
|
||||
final image = _running[id];
|
||||
if (image == null) return;
|
||||
image.cancelled = true;
|
||||
image.nextFrame?.dispose();
|
||||
image.nextFrame = null;
|
||||
if (image.receivedFrame) {
|
||||
image.detach();
|
||||
if (!image.encoding) _finish(image);
|
||||
}
|
||||
}
|
||||
|
||||
void _drain() {
|
||||
if (_draining || _disposed) return;
|
||||
_draining = true;
|
||||
try {
|
||||
while (_running.length < 4 && _queued.isNotEmpty) {
|
||||
final image = _queued.remove(_queued.keys.first)!;
|
||||
_running[image.id] = image;
|
||||
image.stream = image.provider.resolve(image.config);
|
||||
image.listener = ImageStreamListener(
|
||||
(frame, _) {
|
||||
image.receivedFrame = true;
|
||||
if (image.cancelled || _disposed) {
|
||||
frame.dispose();
|
||||
_finish(image);
|
||||
return;
|
||||
}
|
||||
image.nextFrame?.dispose();
|
||||
image.nextFrame = frame;
|
||||
if (!image.encoding) unawaited(_sendFrames(image));
|
||||
},
|
||||
onError: (Object _, StackTrace? _) {
|
||||
image.receivedFrame = true;
|
||||
if (!image.cancelled && !_disposed) {
|
||||
unawaited(_send(image.id, null));
|
||||
}
|
||||
image.cancelled = true;
|
||||
image.detach();
|
||||
if (!image.encoding) _finish(image);
|
||||
},
|
||||
);
|
||||
image.stream!.addListener(image.listener!);
|
||||
}
|
||||
} finally {
|
||||
_draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendFrames(_CarouselImage image) async {
|
||||
image.encoding = true;
|
||||
try {
|
||||
while (!_disposed && !image.cancelled && image.nextFrame != null) {
|
||||
final frame = image.nextFrame!;
|
||||
image.nextFrame = null;
|
||||
try {
|
||||
final bytes = await frame.image.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
if (!_disposed && !image.cancelled) {
|
||||
await _send(image.id, bytes?.buffer.asUint8List());
|
||||
}
|
||||
} finally {
|
||||
frame.dispose();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (!_disposed && !image.cancelled) await _send(image.id, null);
|
||||
image.cancelled = true;
|
||||
} finally {
|
||||
image.encoding = false;
|
||||
if (_disposed || image.cancelled) _finish(image);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _send(String id, Uint8List? bytes) async {
|
||||
try {
|
||||
await channel.invokeMethod<void>('imageFrame', {
|
||||
'id': id,
|
||||
'bytes': bytes,
|
||||
});
|
||||
} on PlatformException {
|
||||
// The native view may disappear while a frame crosses the bridge.
|
||||
} on MissingPluginException {
|
||||
// Disposal can remove the channel before an in-flight frame arrives.
|
||||
}
|
||||
}
|
||||
|
||||
void _finish(_CarouselImage image) {
|
||||
image.detach();
|
||||
image.nextFrame?.dispose();
|
||||
image.nextFrame = null;
|
||||
_running.remove(image.id);
|
||||
_drain();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_queued.clear();
|
||||
for (final image in _running.values.toList()) {
|
||||
image.cancelled = true;
|
||||
_finish(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CarouselImage {
|
||||
_CarouselImage(this.id, this.provider, this.config);
|
||||
final String id;
|
||||
final ImageProvider<Object> provider;
|
||||
final ImageConfiguration config;
|
||||
ImageStream? stream;
|
||||
ImageStreamListener? listener;
|
||||
ImageInfo? nextFrame;
|
||||
bool cancelled = false;
|
||||
bool receivedFrame = false;
|
||||
bool encoding = false;
|
||||
|
||||
void detach() {
|
||||
final listener = this.listener;
|
||||
if (listener != null) stream?.removeListener(listener);
|
||||
this.listener = null;
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(container.read(profileAvatarHandoffProvider), isNull);
|
||||
// Finish the deliberately paused transport so its deadline is cancelled.
|
||||
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
|
||||
await tester.pump();
|
||||
});
|
||||
testWidgets('keeps the avatar unbadged while showing the current status', (
|
||||
tester,
|
||||
|
||||
355
mobile/test/shared/widgets/ios_media_carousel_test.dart
Normal file
355
mobile/test/shared/widgets/ios_media_carousel_test.dart
Normal file
@@ -0,0 +1,355 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/shared/widgets/ios_media_carousel.dart';
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
for (final stallBody in [false, true]) {
|
||||
testWidgets(
|
||||
'stalled ${stallBody ? "bodies" : "headers"} abort before queued photo loads',
|
||||
(tester) async {
|
||||
final client = (await tester.runAsync(() async => _TrackingClient()))!;
|
||||
final server = await tester.runAsync(
|
||||
() => HttpServer.bind(InternetAddress.loopbackIPv4, 0),
|
||||
);
|
||||
addTearDown(() async {
|
||||
client.close();
|
||||
await server!.close(force: true);
|
||||
MediaImageProvider.debugResetCooldowns();
|
||||
});
|
||||
final base = 'http://127.0.0.1:${server!.port}';
|
||||
final auth = MediaGetAuthService(baseUrl: base, nsec: null);
|
||||
await tester.runAsync(() async {
|
||||
server.listen((request) {
|
||||
if (request.uri.path == '/4') {
|
||||
request.response.statusCode = 404;
|
||||
unawaited(request.response.close());
|
||||
} else if (stallBody) {
|
||||
request.response.contentLength = 100;
|
||||
request.response.add([0]);
|
||||
unawaited(request.response.flush());
|
||||
}
|
||||
// The first four never finish: only client-side abort can release them.
|
||||
});
|
||||
});
|
||||
final harness = await _mount(
|
||||
tester,
|
||||
List.generate(
|
||||
5,
|
||||
(index) => MediaImageProvider(
|
||||
url: '$base/$index',
|
||||
auth: auth,
|
||||
client: client,
|
||||
requestTimeout: const Duration(seconds: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
for (var index = 0; index < 4; index++) {
|
||||
await harness.send('subscribeImage', {
|
||||
'id': '$index',
|
||||
'index': index,
|
||||
});
|
||||
await harness.send('cancelImage', '$index');
|
||||
}
|
||||
await harness.send('subscribeImage', {'id': 'latest', 'index': 4});
|
||||
expect(client.started, 4);
|
||||
await harness.waitForFrames(1);
|
||||
await client.retired.future.timeout(const Duration(seconds: 5));
|
||||
});
|
||||
expect(client.started, 5);
|
||||
expect(client.peak, 4);
|
||||
expect(client.aborted, 4);
|
||||
expect(client.active, 0);
|
||||
expect(harness.frames, [
|
||||
{'id': 'latest', 'bytes': null},
|
||||
]);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'native callbacks and disposal release subscribed images',
|
||||
(tester) async {
|
||||
final provider = _TestImage();
|
||||
final harness = await _mount(tester, [provider]);
|
||||
await harness.send('open', 0);
|
||||
await harness.send('open', 2);
|
||||
await harness.send('page', 0);
|
||||
await harness.send('scroll', 21.5);
|
||||
expect(harness.opened, [0]);
|
||||
expect(harness.pages, [0]);
|
||||
expect(harness.offsets, [21.5]);
|
||||
await harness.send('subscribeImage', {'id': 'a', 'index': 0});
|
||||
expect(provider.completer.listeners, hasLength(1));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(provider.completer.listeners, isEmpty);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'multi-image native gallery forwards continuing animation frames',
|
||||
(tester) async {
|
||||
final animated = _TestImage();
|
||||
final still = _TestImage();
|
||||
final harness = await _mount(tester, [animated, still]);
|
||||
await harness.send('subscribeImage', {'id': 'animated', 'index': 0});
|
||||
await harness.send('subscribeImage', {'id': 'still', 'index': 1});
|
||||
final decoded = await tester.runAsync(() async {
|
||||
// Two 1x1 GIF frames with different palette indices: red, then blue.
|
||||
final codec = await ui.instantiateImageCodec(
|
||||
base64Decode(
|
||||
'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7',
|
||||
),
|
||||
);
|
||||
try {
|
||||
expect(codec.frameCount, 2);
|
||||
return [
|
||||
(await codec.getNextFrame()).image,
|
||||
(await codec.getNextFrame()).image,
|
||||
];
|
||||
} finally {
|
||||
codec.dispose();
|
||||
}
|
||||
});
|
||||
await tester.runAsync(() async {
|
||||
animated.completer.emit(decoded![0]);
|
||||
await harness.waitForFrames(1);
|
||||
animated.completer.emit(decoded[1]);
|
||||
await harness.waitForFrames(2);
|
||||
});
|
||||
expect(harness.frames.map((f) => f['id']), ['animated', 'animated']);
|
||||
expect(
|
||||
harness.frames[0]['bytes'],
|
||||
isNot(orderedEquals(harness.frames[1]['bytes'] as Uint8List)),
|
||||
);
|
||||
await harness.send('cancelImage', 'animated');
|
||||
expect(animated.completer.listeners, isEmpty);
|
||||
final count = harness.frames.length;
|
||||
final green = await tester.runAsync(() => _frame(Colors.green));
|
||||
animated.completer.emit(green!);
|
||||
await tester.pump();
|
||||
expect(harness.frames, hasLength(count));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(still.completer.listeners, isEmpty);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'rapid reuse bounds unresolved loads and drops cancelled queued requests',
|
||||
(tester) async {
|
||||
final providers = List.generate(20, (_) => _TestImage());
|
||||
final harness = await _mount(tester, providers);
|
||||
for (var index = 0; index < 20; index++) {
|
||||
await harness.send('subscribeImage', {'id': '$index', 'index': index});
|
||||
if (index < 19) await harness.send('cancelImage', '$index');
|
||||
}
|
||||
expect(providers.where((p) => p.resolutions > 0), hasLength(4));
|
||||
expect(
|
||||
providers.skip(4).take(15).every((p) => p.completer.listeners.isEmpty),
|
||||
isTrue,
|
||||
);
|
||||
// Retired pending providers retain a bounded completion observer because
|
||||
// transport termination must precede releasing that slot.
|
||||
final image = await tester.runAsync(() => _frame(Colors.red));
|
||||
providers[0].completer.emit(image!);
|
||||
await tester.pump();
|
||||
expect(providers[0].completer.listeners, isEmpty);
|
||||
expect(providers[19].resolutions, 1);
|
||||
expect(
|
||||
harness.frames,
|
||||
isEmpty,
|
||||
reason: 'Cancelled loads must never reach UIKit',
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(providers.every((p) => p.completer.listeners.isEmpty), isTrue);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Image> _frame(Color color) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
Canvas(
|
||||
recorder,
|
||||
).drawRect(const Rect.fromLTWH(0, 0, 2, 2), Paint()..color = color);
|
||||
final picture = recorder.endRecording();
|
||||
try {
|
||||
return await picture.toImage(2, 2);
|
||||
} finally {
|
||||
picture.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Harness> _mount(
|
||||
WidgetTester tester,
|
||||
List<ImageProvider<Object>> providers,
|
||||
) async {
|
||||
final harness = _Harness(tester);
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
|
||||
call,
|
||||
) async {
|
||||
if (call.method == 'create') {
|
||||
final id = (call.arguments as Map)['id'];
|
||||
harness.channel = MethodChannel('${IosMediaCarousel.viewType}/$id');
|
||||
messenger.setMockMethodCallHandler(harness.channel, (call) async {
|
||||
if (call.method == 'imageFrame') {
|
||||
harness.frames.add(Map<String, Object?>.from(call.arguments as Map));
|
||||
harness.changed?.complete();
|
||||
harness.changed = null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
|
||||
messenger.setMockMethodCallHandler(harness.channel, null);
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SizedBox(
|
||||
width: 300,
|
||||
height: 200,
|
||||
child: IosMediaCarousel(
|
||||
providers: providers,
|
||||
labels: List.generate(providers.length, (i) => 'Photo $i'),
|
||||
width: 300,
|
||||
gutter: 20,
|
||||
gap: 4,
|
||||
trailingPadding: 20,
|
||||
radius: 16,
|
||||
onOpen: harness.opened.add,
|
||||
onScroll: harness.offsets.add,
|
||||
onPageChanged: harness.pages.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return harness;
|
||||
}
|
||||
|
||||
class _Harness {
|
||||
_Harness(this.tester);
|
||||
final WidgetTester tester;
|
||||
late MethodChannel channel;
|
||||
final opened = <int>[];
|
||||
final pages = <int>[];
|
||||
final offsets = <double>[];
|
||||
final frames = <Map<String, Object?>>[];
|
||||
Completer<void>? changed;
|
||||
Future<void> waitForFrames(int count) async {
|
||||
while (frames.length < count) {
|
||||
changed = Completer<void>();
|
||||
await changed!.future.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(String method, Object argument) async {
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
channel.name,
|
||||
channel.codec.encodeMethodCall(MethodCall(method, argument)),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TestImage extends ImageProvider<_TestImage> {
|
||||
_TestImage() {
|
||||
final handle = completer.keepAlive();
|
||||
addTearDown(handle.dispose);
|
||||
}
|
||||
final completer = _TestCompleter();
|
||||
int resolutions = 0;
|
||||
@override
|
||||
Future<_TestImage> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture(this);
|
||||
@override
|
||||
void resolveStreamForKey(
|
||||
ImageConfiguration configuration,
|
||||
ImageStream stream,
|
||||
_TestImage key,
|
||||
ImageErrorListener handleError,
|
||||
) {
|
||||
resolutions++;
|
||||
stream.setCompleter(completer);
|
||||
}
|
||||
}
|
||||
|
||||
class _TestCompleter extends ImageStreamCompleter {
|
||||
final listeners = <ImageStreamListener>{};
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
listeners.add(listener);
|
||||
super.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
listeners.remove(listener);
|
||||
super.removeListener(listener);
|
||||
}
|
||||
|
||||
void emit(ui.Image image) => setImage(ImageInfo(image: image));
|
||||
}
|
||||
|
||||
// Real IO transport: count through body completion, not just response headers.
|
||||
class _TrackingClient extends http.BaseClient {
|
||||
final inner = IOClient(_RealHttpOverrides().createHttpClient(null));
|
||||
int active = 0, peak = 0, started = 0, aborted = 0;
|
||||
final retired = Completer<void>();
|
||||
void recordAbort() {
|
||||
aborted++;
|
||||
if (aborted == 4) retired.complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
started++;
|
||||
active++;
|
||||
if (active > peak) peak = active;
|
||||
try {
|
||||
final response = await inner.send(request);
|
||||
return http.StreamedResponse(_body(response.stream), response.statusCode);
|
||||
} catch (error) {
|
||||
active--;
|
||||
if (error is http.RequestAbortedException) recordAbort();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<int>> _body(Stream<List<int>> stream) async* {
|
||||
try {
|
||||
await for (final chunk in stream) {
|
||||
yield chunk;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error is http.RequestAbortedException) recordAbort();
|
||||
rethrow;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
|
||||
class _RealHttpOverrides extends HttpOverrides {}
|
||||
19
scripts/test-mobile-carousel.sh
Normal file
19
scripts/test-mobile-carousel.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Test the actual UIKit carousel and Flutter bridge on a booted iOS simulator.
|
||||
# Usage: /bin/bash scripts/test-mobile-carousel.sh <simulator-udid>
|
||||
set -euo pipefail
|
||||
repo_root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
device=${1:?Pass a booted iOS simulator UDID}
|
||||
flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig")
|
||||
frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator"
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -r "$fixture"' EXIT
|
||||
xcrun --sdk iphonesimulator swiftc \
|
||||
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
|
||||
-target "$(uname -m)-apple-ios15.0-simulator" \
|
||||
-F "$frameworks" -framework Flutter \
|
||||
-Xlinker -rpath -Xlinker "$frameworks" \
|
||||
"$repo_root/mobile/ios/Runner/IosMediaCarousel.swift" \
|
||||
"$repo_root/mobile/ios/NativeControlTests/IosMediaCarouselTests.swift" \
|
||||
-o "$fixture/carousel-tests"
|
||||
xcrun simctl spawn "$device" "$fixture/carousel-tests"
|
||||
Reference in New Issue
Block a user