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:
klopez4212
2026-10-15 13:22:12 -04:00
committed by GitHub
parent 9296660d1e
commit d61fe2071d
12 changed files with 1125 additions and 67 deletions

View File

@@ -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;

View File

@@ -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,
),
],
);

View File

@@ -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;

View 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;
}
}