routemaster icon indicating copy to clipboard operation
routemaster copied to clipboard

Store page state

Open tomgilder opened this issue 3 years ago • 7 comments

Add an option to never have pages created, no matter how the user navigates around.

The widgets stay in memory and get restored to their previous state so things like scroll position and entered text remain the same.

tomgilder avatar Apr 29 '21 19:04 tomgilder

I used the approach below, for pages that should be stored and never recreated. It is just copy of the same private class that the CupertinoTabScaffold in the SDK uses, with things renamed a bit.

/// A widget laying out multiple pages with only one active page being built
/// at a time and on stage. Off stage pages' animations are stopped.
class PageSwitchingView extends StatefulWidget {
  const PageSwitchingView({
    required this.currentPageIndex,
    required this.pageCount,
    required this.pageBuilder,
  })  : assert(currentPageIndex != null),
        assert(pageCount != null && pageCount > 0),
        assert(pageBuilder != null);

  final int currentPageIndex;
  final int pageCount;
  final IndexedWidgetBuilder pageBuilder;

  @override
  _PageSwitchingViewState createState() => _PageSwitchingViewState();
}

class _PageSwitchingViewState extends State<PageSwitchingView> {
  final List<bool> shouldBuildPage = <bool>[];
  final List<FocusScopeNode> pageFocusNodes = <FocusScopeNode>[];

  // When focus nodes are no longer needed, we need to dispose of them, but we
  // can't be sure that nothing else is listening to them until this widget is
  // disposed of, so when they are no longer needed, we move them to this list,
  // and dispose of them when we dispose of this widget.
  final List<FocusScopeNode> discardedNodes = <FocusScopeNode>[];

  @override
  void initState() {
    super.initState();
    shouldBuildPage.addAll(List<bool>.filled(widget.pageCount, false));
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _focusActivePage();
  }

  @override
  void didUpdateWidget(PageSwitchingView oldWidget) {
    super.didUpdateWidget(oldWidget);

    // Only partially invalidate the page cache to avoid breaking the current
    // behavior. We assume that the only possible change is either:
    // - new pages are appended to the tab list, or
    // - some trailing pages are removed.
    // If the above assumption is not true, some pages may lose their state.
    final int lengthDiff = widget.pageCount - shouldBuildPage.length;
    if (lengthDiff > 0) {
      shouldBuildPage.addAll(List<bool>.filled(lengthDiff, false));
    } else if (lengthDiff < 0) {
      shouldBuildPage.removeRange(widget.pageCount, shouldBuildPage.length);
    }
    _focusActivePage();
  }

  // Will focus the active tab if the FocusScope above it has focus already. If
  // not, then it will just mark it as the preferred focus for that scope.
  void _focusActivePage() {
    if (pageFocusNodes.length != widget.pageCount) {
      if (pageFocusNodes.length > widget.pageCount) {
        discardedNodes.addAll(pageFocusNodes.sublist(widget.pageCount));
        pageFocusNodes.removeRange(widget.pageCount, pageFocusNodes.length);
      } else {
        pageFocusNodes.addAll(
          List<FocusScopeNode>.generate(
            widget.pageCount - pageFocusNodes.length,
            (int index) => FocusScopeNode(
                debugLabel: 'Page ${index + pageFocusNodes.length}'),
          ),
        );
      }
    }
    FocusScope.of(context)
        .setFirstFocus(pageFocusNodes[widget.currentPageIndex]);
  }

  @override
  void dispose() {
    for (final FocusScopeNode focusScopeNode in pageFocusNodes) {
      focusScopeNode.dispose();
    }
    for (final FocusScopeNode focusScopeNode in discardedNodes) {
      focusScopeNode.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: List<Widget>.generate(widget.pageCount, (int index) {
        final bool active = index == widget.currentPageIndex;
        shouldBuildPage[index] = active || shouldBuildPage[index];

        return HeroMode(
          enabled: active,
          child: Offstage(
            offstage: !active,
            child: TickerMode(
              enabled: active,
              child: FocusScope(
                node: pageFocusNodes[index],
                child: Builder(builder: (BuildContext context) {
                  return shouldBuildPage[index]
                      ? widget.pageBuilder(context, index)
                      : Container();
                }),
              ),
            ),
          ),
        );
      }),
    );
  }
}

rydmike avatar May 09 '21 17:05 rydmike

Would this include keeping indexed pages alive in the background? I'm trying to keep certain tabs alive so they don't reload when navigating between them.

coleweinman avatar May 21 '21 18:05 coleweinman

@RedTech64 It does indeed do so.

However, if you are using the Routemaster setup for CupertinoTabBar as a top level navigator, as shown eg in its bundled mobile_app demo, those pages then already keep state, then again and if it if for a TabBar view and navigator, you can use this lighter approach mentioned by @tomgilder here https://github.com/tomgilder/routemaster/issues/87

But if it is for some other type of indexed navigator, some custom one, or why not eg Flutter SDK NavigationRail, then you might indeed need to consider the above approach, at least until Tom builds in support for it in Routemaster, if he plans to do so.

I don't have public repo showing how to use the above thing yet, but I might add it to this modified version of the mobile_app demo and use the NavigationRail for a mock adaptive setup where it switches from bottom navbar to rail/menu. I have a bunch of question and potential issues I want to demonstrate to Tom concerning its (and similar index navigators) usage as well.

rydmike avatar May 21 '21 21:05 rydmike

@rydmike Thanks

I'm using a NavigationRail so a modified version of mobile_app with an example would be great.

coleweinman avatar May 21 '21 23:05 coleweinman

Why have the pages created in memory? That sounds like a performance loss and can be potentially heavy. Also, it makes the library scope creep.

Data can be cached (up to the developer to do this), for quick view restoration, but view caching is a dangerous (gets in the way of lifecycle dependent calls, init etc.) and as stated, heavy.

sp00ne avatar Aug 08 '21 11:08 sp00ne

@sp00ne I agree, I don't want to add lots of scope creep and complexity here, and there are performance issues to consider.

Flutter already has state restoration built-in which might be the best way to tackle this issue.

I'm not sure how well it would work with Routemaster though, I haven't tried.

tomgilder avatar Aug 15 '21 11:08 tomgilder

Flutter already has state restoration built-in which might be the best way to tackle this issue.

it doesn't work with routemaster, i've tried

zombie6888 avatar Dec 15 '21 16:12 zombie6888