diff --git a/mindbox/CHANGELOG.md b/mindbox/CHANGELOG.md index 48b3a82..1fd38a5 100644 --- a/mindbox/CHANGELOG.md +++ b/mindbox/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -* Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel. +* Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel. In a lazy list the block is kept alive off screen by default (`keepAlive`), so scrolling back shows the same page without a reload. ## 2.15.2 diff --git a/mindbox/README.md b/mindbox/README.md index b20b041..f503d84 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -78,6 +78,21 @@ MindboxEmbeddedBlock( reload. `timeout` is fixed when the block is created — a new value is ignored and reported to the log; give the widget a new `Key` to load a block on a new budget. +In a lazy list — a `ListView`, a `GridView` — the block asks to be kept alive off screen by default, +the way the native blocks behave in a scroll: a block scrolled far away keeps its page, and on the +way back it shows the same content at once, with no reload and no shimmer. The price is memory — +every kept block holds its web page for as long as the list lives, and the whole row it stands in is +kept with it. A screen with many blocks can opt out with `keepAlive: false`, and then the block is +disposed with its row like any other widget. + +```dart +ListView.builder( + itemBuilder: (_, index) => index == 0 + ? const MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104) + : ProductRow(index), +) +``` + Available on iOS and Android. On any other platform the block collapses right away and reports `onFail`, so a layout that hides its section on failure behaves the same everywhere. diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index f84c7dd..ccd179b 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -2,6 +2,8 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; @@ -51,6 +53,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { required this.placeSystemName, required this.height, this.timeout, + this.keepAlive = true, this.placeholder, this.errorBuilder, this.onLoad, @@ -88,6 +91,32 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// to the log. Give the widget a new [Key] to load a block on a new budget. final Duration? timeout; + /// Whether the block survives being scrolled out of a lazy list. + /// + /// A `ListView`, a `GridView` or any other lazy sliver builds only what is near the viewport and + /// throws the rest away — a block scrolled far enough would be disposed with its row, and on the + /// way back a *new* block would load its content from scratch: a full cycle with the shimmer on + /// every pass across the screen. The native iOS and Android blocks do not behave that way: a view + /// in a scroll is paused off screen, not destroyed, and its page is shown again as it was. + /// + /// `true` — the default — asks the list to keep the block alive, so it matches the native blocks: + /// off screen its content is paused, and on the way back the same page is shown at once, with no + /// reload, no shimmer and no second [onLoad]. Outside a lazy list the flag changes nothing. + /// + /// The pause is the widget's doing, not only the platform's: a kept block that the list has + /// scrolled out of view is reported to the native block as hidden, the same way a block behind a + /// pushed route is. On iOS the platform view also leaves the window, on Android it stays attached + /// and would otherwise count as visible — running its page, spending its waiting budget and + /// accounting a show nobody sees. + /// + /// The price is memory: every kept block holds its web page for as long as the list lives, and + /// the request keeps the whole row alive — the row's own widgets with it — in every lazy list + /// the block stands in, a carousel inside a feed included. A screen with many blocks that is + /// better off paying a reload than holding them all can turn this off, and then the block is + /// disposed with its row exactly as any other widget is. Live: a new value takes effect on the + /// block in place. + final bool keepAlive; + /// Built instead of the SDK shimmer while the block is loading. /// /// Fills the whole place, as the native placeholder does: the widget is given the block's full @@ -126,6 +155,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { placeSystemName: placeSystemName, height: height, timeout: timeout, + keepAlive: keepAlive, placeholder: placeholder, errorBuilder: errorBuilder, onLoad: onLoad, @@ -140,6 +170,7 @@ class _EmbeddedBlock extends StatefulWidget { required this.placeSystemName, required this.height, required this.timeout, + required this.keepAlive, required this.placeholder, required this.errorBuilder, required this.onLoad, @@ -149,6 +180,7 @@ class _EmbeddedBlock extends StatefulWidget { final String placeSystemName; final double height; final Duration? timeout; + final bool keepAlive; final WidgetBuilder? placeholder; final WidgetBuilder? errorBuilder; final VoidCallback? onLoad; @@ -158,7 +190,15 @@ class _EmbeddedBlock extends StatefulWidget { State<_EmbeddedBlock> createState() => _EmbeddedBlockState(); } -class _EmbeddedBlockState extends State<_EmbeddedBlock> { +/// Kept alive in a lazy list by default: the platform view — and the SDK container with its page +/// behind it — is what a reload costs, and a row of a `ListView` is rebuilt on every pass across the +/// screen. Off screen the block is paused rather than destroyed — by the window on iOS, and by the +/// hidden signal this widget sends on Android — so keeping it costs memory, not work; see +/// [MindboxEmbeddedBlock.keepAlive]. A platform without a native block has nothing worth keeping. +class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => widget.keepAlive && _isSupported; + double get _height => widget.height.isFinite ? math.max(0, widget.height) : 0; late final Duration? _creationTimeout; @@ -175,7 +215,15 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { bool? _syncedHasErrorView; bool? _syncedHostVisible; - bool _isHostVisible = true; + bool _isTickerEnabled = true; + + /// A list keeps the block's row alive, and it is out of view. Read from the slivers' parent + /// data after every frame for as long as the block is mounted. + bool _isKeptAliveOffscreen = false; + + bool _isKeptAliveCheckArmed = false; + + bool get _isHostVisible => _isTickerEnabled && !_isKeptAliveOffscreen; bool get _hasPlaceholder => widget.placeholder != null; @@ -192,6 +240,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { _creationTimeout = widget.timeout; _warnIfPlaceIsPadded(); _warnIfHeightReservesNoSpace(); + _armKeptAliveCheck(); if (!_isSupported) { WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { if (!mounted) { @@ -207,13 +256,16 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { void didChangeDependencies() { super.didChangeDependencies(); // ignore: deprecated_member_use - _isHostVisible = TickerMode.of(context); + _isTickerEnabled = TickerMode.of(context); _pushHostVisible(); } @override void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.keepAlive != widget.keepAlive) { + updateKeepAlive(); + } _warnIfTimeoutIsIgnored(); _pushStandIns(); } @@ -232,6 +284,8 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override Widget build(BuildContext context) { + // The mixin's build is what hands the list the keep-alive handle; its widget is not used. + super.build(context); final Widget? hostLayer = _hostLayer(context); return SizedBox( @@ -390,6 +444,60 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); } + /// Whether a list has parked the block off screen. A lazy sliver flips `keptAlive` on the + /// child's parent data while it lays out, so the answer is read once the frame is done. + /// + /// The walk goes all the way up and answers for *every* enclosing lazy list, not the nearest + /// one: the keep-alive request travels past the first list to all the others, so a carousel + /// inside a feed is parked by the feed while the carousel's own parent data still says the + /// block is in place. A block outside any lazy list finds nothing and is never off screen by + /// this measure. + bool _readKeptAliveOffscreen() { + RenderObject? node = context.findRenderObject(); + while (node != null) { + final ParentData? parentData = node.parentData; + if (parentData is KeepAliveParentDataMixin && parentData.keptAlive) { + return true; + } + + // `parent` is typed as the abstract node on the oldest Flutter the plugin speaks to, and as + // a render object on the newest — the check reads on both without a cast to warn about. + final Object? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return false; + } + + /// Re-armed after every frame for as long as the block is mounted, whatever its own + /// [MindboxEmbeddedBlock.keepAlive] says: the block's request is not the only thing that can + /// park its row — any keep-alive client in the row does, another block among them — and a + /// block parked by someone else has to be hidden and shown all the same. Tying the check to + /// the block's own flag would also leave a block that turned the flag off while parked hidden + /// for good once its row came back. + /// + /// A post-frame callback runs only when a frame is produced, so a list that stands still costs + /// nothing; a list that scrolls pays a short walk up the render tree per block per frame. + void _armKeptAliveCheck() { + if (_isKeptAliveCheckArmed || !_isSupported) { + return; + } + + _isKeptAliveCheckArmed = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _isKeptAliveCheckArmed = false; + if (!mounted) { + return; + } + + final bool keptAliveOffscreen = _readKeptAliveOffscreen(); + if (keptAliveOffscreen != _isKeptAliveOffscreen) { + _isKeptAliveOffscreen = keptAliveOffscreen; + _pushHostVisible(); + } + _armKeptAliveCheck(); + }); + } + void _invoke(MethodChannel channel, String method, Object? arguments) { channel.invokeMethod(method, arguments).catchError((Object error) { debugPrint('[MindboxEmbeddedBlock] $method for block "${widget.placeSystemName}" ' diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 0230aba..0b244be 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -643,4 +643,363 @@ void main() { } }); }); + group('A lazy list', () { + late List methods; + late List hostVisible; + + setUp(() { + methods = []; + hostVisible = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + if (call.method != 'create') { + return null; + } + + final Map arguments = call.arguments as Map; + final int viewId = arguments['id']! as int; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(embeddedBlockChannelName(viewId)), + (MethodCall call) async { + methods.add(call.method); + if (call.method == EmbeddedBlockMethods.setHostVisible) { + hostVisible.add(call.arguments as bool); + } + return null; + }, + ); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + void testOn(TargetPlatform platform, String description, + Future Function(WidgetTester) body) { + testWidgets(description, (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + + // Both platforms host a native block, and the widget's keep-alive and hidden/shown paths + // are the same Dart on both — only the teardown differs: on iOS the widget sends `release` + // itself, on Android the platform view's own dispose hook does, so `release` is asserted + // on iOS only. + void testOnBoth(String description, Future Function(WidgetTester) body) { + for (final TargetPlatform platform in [ + TargetPlatform.iOS, + TargetPlatform.android, + ]) { + testOn(platform, '$description (${platform.name})', body); + } + } + + // A row that asks to be kept alive on its own, the way a host's stateful row widget might. + Widget keptRow({required Widget child}) => _KeptAliveRow(child: child); + + // A list ten screens tall with the block in its first row. The test viewport is 600 logical + // pixels high and the list caches 250 more, so a scroll of a few thousand takes the row far + // past anything the list keeps around on its own. + Future pumpList(WidgetTester tester, {required bool keepAlive}) { + return tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + keepAlive: keepAlive, + ) + : const SizedBox(height: 104), + ), + )); + } + + Future scrollBy(WidgetTester tester, double offset) async { + await tester.drag(find.byType(ListView), Offset(0, -offset)); + await tester.pumpAndSettle(); + } + + int nativeBlocksCreated() => + methods.where((String method) => method == EmbeddedBlockMethods.sync).length; + + testOnBoth('A block scrolled away survives the row and comes back without a reload', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + expect(nativeBlocksCreated(), 1); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock), findsNothing); + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + + await scrollBy(tester, -5000); + + expect(find.byType(MindboxEmbeddedBlock), findsOneWidget); + expect(nativeBlocksCreated(), 1); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + }); + + testOnBoth('A host that opts out gets the block disposed with its row and rebuilt on the way back', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(nativeBlocksCreated(), 1); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } + + await scrollBy(tester, -5000); + + expect(find.byType(MindboxEmbeddedBlock), findsOneWidget); + expect(nativeBlocksCreated(), 2); + }); + + testOnBoth('Opting out of keep-alive takes effect on the live block', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } + }); + + testOnBoth('A kept block scrolled out of view is reported hidden, and shown again on the way back', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + await scrollBy(tester, 5000); + + expect(hostVisible, [true, false]); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + }); + + testOnBoth('A kept block still in view is not reported hidden by the check', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + // Short of the cache extent: the row is out of the viewport but still live, and a live row + // is for the platform to pause, not the widget. + await scrollBy(tester, 150); + await tester.pump(); + await tester.pump(); + + expect(hostVisible, [true]); + }); + + testOnBoth('Outside a lazy list the check never hides the block', (WidgetTester tester) async { + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ], + ), + )); + await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(); + + expect(hostVisible, [true]); + }); + + testOnBoth('A block in a carousel inside a feed is reported hidden when the feed parks the row', + (WidgetTester tester) async { + const Key feed = Key('feed'); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + key: feed, + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? SizedBox( + height: 104, + child: ListView( + scrollDirection: Axis.horizontal, + children: const [ + SizedBox( + width: 300, + child: MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ), + SizedBox(width: 300), + ], + ), + ) + : const SizedBox(height: 104), + ), + )); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + // The carousel's own parent data never parks the block — the feed does, one level up. + await tester.drag(find.byKey(feed), const Offset(0, -5000)); + await tester.pumpAndSettle(); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(hostVisible, [true, false]); + + await tester.drag(find.byKey(feed), const Offset(0, 5000)); + await tester.pumpAndSettle(); + + expect(hostVisible, [true, false, true]); + }); + + testOnBoth('Opting out while parked lets the list drop the block without showing it first', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + await scrollBy(tester, 5000); + expect(hostVisible, [true, false]); + + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + expect(hostVisible, [true, false]); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + }); + + testOnBoth('A block behind a disabled TickerMode stays hidden through parking and return', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TickerMode( + enabled: false, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? const MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104) + : const SizedBox(height: 104), + ), + ), + )); + await tester.pumpAndSettle(); + expect(hostVisible, [false]); + + await scrollBy(tester, 5000); + await scrollBy(tester, -5000); + + expect(hostVisible, [false]); + }); + + Future pumpKeptRowList(WidgetTester tester, {required bool keepAlive}) { + return tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? keptRow( + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + keepAlive: keepAlive, + ), + ) + : const SizedBox(height: 104), + ), + )); + } + + testOnBoth('A block that opted out but sits in a row someone else keeps is still hidden and shown', + (WidgetTester tester) async { + await pumpKeptRowList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + await scrollBy(tester, 5000); + + // The row's own client keeps it, so the block survives without asking — and must not run. + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + expect(hostVisible, [true, false]); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + expect(nativeBlocksCreated(), 1); + }); + + testOnBoth('Opting out while parked in a row someone else keeps does not leave the block hidden', + (WidgetTester tester) async { + await pumpKeptRowList(tester, keepAlive: true); + await tester.pumpAndSettle(); + await scrollBy(tester, 5000); + expect(hostVisible, [true, false]); + + await pumpKeptRowList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + expect(nativeBlocksCreated(), 1); + }); + + testOnBoth('Opting back into keep-alive takes effect on the live block', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + }); + }); + +} + +/// A list row with a keep-alive client of its own, as a host's stateful row widget might have. +class _KeptAliveRow extends StatefulWidget { + const _KeptAliveRow({required this.child}); + + final Widget child; + + @override + State<_KeptAliveRow> createState() => _KeptAliveRowState(); +} + +class _KeptAliveRowState extends State<_KeptAliveRow> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } }