Как заставить ListView сохранить свою прокрутку при переходе на другой маршрут? - PullRequest
0 голосов
/ 13 октября 2019

Я хочу выполнить переход от слайда к праву в моем приложении флаттера. Проблема в том, что переход маршрута создает своего рода новый экземпляр страницы, с которого я хочу перейти, и поэтому ListView прокрутка сбрасывается.

Смотрите видео

Воткод страницы, который содержит ListView:

import 'package:app/components/SingleTouchRecognizer.dart';
import 'package:app/components/albumArt.dart';
import 'package:app/components/bottomTrackPanel.dart';
import 'package:app/components/search.dart';
import 'package:app/player/permissions.dart';
import 'package:app/player/playerWidgets.dart';
import 'package:app/player/playlist.dart';
import 'package:app/player/song.dart';
import 'package:app/routes/playerRoute.dart';
import 'package:app/routes/settingsRoute.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:app/player/player.dart';
import 'scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:app/components/refresh_indicator.dart';


/// List of fetched tracks
class TrackList extends StatefulWidget {
  final EdgeInsets bottomPadding;
  TrackList({Key key, this.bottomPadding: const EdgeInsets.only(bottom: 0.0)})
      : super(key: key);

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

class _TrackListState extends State<TrackList> {
  // As you can see here's a page storage key, that works normally with
  // page transitions, that don't move exit route
  static final PageStorageKey _pageScrollKey = PageStorageKey('MainListView');

 // Other methods...

  Future<void> _handleClickSettings() async {
    Navigator.pop(context);
    await Future.delayed(Duration(
        milliseconds: 246 + 20)); // Wait before pop sidebar closes plus delay
    Navigator.of(context).push(createSettingsRoute(widget));
  }

  @override
  Widget build(BuildContext context) {
    if (Permissions.permissionStorageStatus != MyPermissionStatus.granted)
      // Code that displays to user button to re-request permissions
    if (PlaylistControl.songsEmpty(PlaylistType.global))
      // Code that displays to user a message that there're not songs on his device
    return Scaffold(
      drawer: Theme(
        data: Theme.of(context).copyWith(
          canvasColor:
              Color(0xff070707), //This will change the drawer background
        ),
        child: Drawer(
          child: ListView(
            physics: NeverScrollableScrollPhysics(),
            // Important: Remove any padding from the ListView.
            padding: EdgeInsets.zero,
            children: <Widget>[
              Container(
                // height: 100.0,
                padding:
                    const EdgeInsets.only(left: 15.0, top: 40.0, bottom: 20.0),
                child: Text('Меню', style: TextStyle(fontSize: 35.0)),
              ),
              ListTile(
                  title: Text('Настройки',
                      style: TextStyle(
                          fontSize: 17.0, color: Colors.deepPurple.shade300)),
                  onTap: _handleClickSettings),
            ],
          ),
        ),
      ),
      appBar: AppBar(
        // automaticallyImplyLeading: false,
        // leading: IconButton(
        //     icon: Icon(Icons.menu),
        //   ),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.sort),
            onPressed: () {
              _showSortModal();
            },
          ),
        ],
        titleSpacing: 0.0,
        title: Padding(
          padding: const EdgeInsets.only(left: 0.0),
          child: ClipRRect(
            // FIXME: cliprrect doesn't work for material for some reason
            borderRadius: BorderRadius.circular(10),
            child: GestureDetector(
              onTap: _showSearch,
              child: FractionallySizedBox(
                // heightFactor: 1,
                widthFactor: 1,
                child: Container(
                  padding: const EdgeInsets.only(
                      left: 12.0, top: 10.0, bottom: 10.0),
                  decoration: BoxDecoration(
                    color: Colors.white.withOpacity(0.05),
                  ),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Text(
                        'Поиск треков на устройстве',
                        style: TextStyle(
                            color: Theme.of(context).hintColor, fontSize: 17),
                      )
                    ],
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
      body: Stack(
        children: <Widget>[
          Padding(
            padding: widget.bottomPadding,
            child: Container(
              child: CustomRefreshIndicator(
                color: Colors.white,
                strokeWidth: 2.5,
                key: _refreshIndicatorKey,
                onRefresh: _refreshHandler,
                child: SingleTouchRecognizerWidget(
                  child: Container(
                    child: ListView.builder(
                      key: _pageScrollKey, // Here's key!
                      itemCount: PlaylistControl.globalPlaylist.length,
                      padding: EdgeInsets.only(bottom: 10, top: 5),
                      itemBuilder: (context, index) {
                        return StreamBuilder(
                            stream: PlaylistControl.onSongChange,
                            builder: (context, snapshot) {
                              return TrackTile(
                                index,
                                key: UniqueKey(),
                                playing: index ==
                                    PlaylistControl.currentSongIndex(
                                        PlaylistType.global),
                                additionalClickCallback: () {
                                  PlaylistControl.resetPlaylists();
                                },
                              );
                            });
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
          BottomTrackPanel(),
        ],
      ),
    );
  }
}

Вот так я создаю новый маршрут

/// @oldRoute needed cause this route transition utilizes `SlideStackRightRoute`
Route createSettingsRoute(Widget oldRoute) {
  return SlideStackRightRoute(exitPage: oldRoute, enterPage: SettingsRoute());
}

И, наконец, перехожу к самому классу перехода справа

import 'package:flutter/material.dart';

/// Creates cupertino-like route transition, where new route pushes old from right to left
class SlideStackRightRoute extends PageRouteBuilder {
  final Widget enterPage;
  final Widget exitPage;
  static var exBegin = Offset(0.0, 0.0);
  static var exEnd = Offset(-0.5, 0.0);
  static var entBegin = Offset(1.0, 0.0);
  static var entEnd = Offset.zero;
  static var curveIn = Curves.easeOutSine;
  static var curveOut = Curves.easeInSine;

  SlideStackRightRoute({@required this.exitPage, @required this.enterPage})
      : super(
          transitionDuration: Duration(milliseconds: 400),
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) =>
              enterPage,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) =>
              Stack(
            children: <Widget>[
              SlideTransition(
                position: Tween(begin: exBegin, end: exEnd)
                    .chain(CurveTween(curve: curveIn))
                    .chain(CurveTween(curve: curveOut))
                    .animate(animation),
                child: Container(
                    foregroundDecoration: BoxDecoration(
                      color: Colors.black.withOpacity(animation.value / 2),
                    ),
                    child: exitPage),
              ),
              SlideTransition(
                position: Tween(begin: entBegin, end: entEnd)
                    .chain(CurveTween(curve: curveIn))
                    .chain(CurveTween(curve: curveOut))
                    .animate(animation),
                child: enterPage,
              )
            ],
          ),
        );
}

1 Ответ

0 голосов
/ 14 октября 2019

UPD2:

Я обнаружил secondaryAnimation свойство в PageRouteBuilder, которое делает именно то, что я хочу. И в итоге мы создали именованные маршруты и обработали их через onGenerateRoute следующим образом:

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  /// Needed to disable animations on some routes
  String _currentRoute = "/";

 // Other fields and methods...

  /// Changes the value of `_currentRoute`
  void _setCurrentRoute(String newValue) {
    _currentRoute = newValue;
  }

  /// Check the equality of `_currentRoute` to some value
  bool _currentRouteEquals(String value) {
    return _currentRoute == value;
  }


  @override
  Widget build(BuildContext context) {
    return MaterialApp(

      // Other parameters...

      initialRoute: "/",

      onGenerateRoute: (settings) {

        // Note that `onGenerateRoute` doesn't get called when user pops from some screen
        _setCurrentRoute(settings.name);

        if (settings.isInitialRoute) {
          return createRouteTransition<WillPopScope>(
            checkExitAnimationEnabled: () => _currentRouteEquals("/settings"),
            checkEntAnimationEnabled: () => false,
            exitCurve: Curves.linearToEaseOut,
            exitReverseCurve: Curves.easeInToLinear,
            maintainState: true,
            route: WillPopScope(child: MainRoute(), onWillPop: _handleHomePop),
          );
        } else if (settings.name == "/player") {
          return createRouteTransition<PlayerRoute>(
            entCurve: Curves.linearToEaseOut,
            entReverseCurve: Curves.fastOutSlowIn,
            exitCurve: Curves.linearToEaseOut,
            exitReverseCurve: Curves.easeInToLinear,
            entBegin: Offset(0.0, 1.0),
            entIgnoreEvents: true,
            checkExitAnimationEnabled: () => _currentRouteEquals("/exif"),
            transitionDuration: const Duration(milliseconds: 500),
            route: PlayerRoute(),
          );
        } else if (settings.name == "/settings") {
          return createRouteTransition<SettingsRoute>(
            entCurve: Curves.linearToEaseOut,
            entReverseCurve: Curves.easeInToLinear,
            route: SettingsRoute(),
          );
        } else if (settings.name == "/exif") {
          return createRouteTransition<ExifRoute>(
            entCurve: Curves.linearToEaseOut,
            entReverseCurve: Curves.easeInToLinear,
            route: ExifRoute(),
          );
        } else if (settings.name == "/search") {
          return (settings.arguments as Map<String, Route>)["route"];
        }

        // FIXME: add unknown route
        return null;
      },
    );
  }
}

А теперь взглянем на createRouteTransition function

import 'package:flutter/material.dart';

/// Type for function that returns boolean
typedef BoolFunction = bool Function();

/// Returns `PageRouteBuilder` that performs slide to right animation
PageRouteBuilder<T> createRouteTransition<T extends Widget>({
  @required final T route,

  /// Function that checks whether to play enter animation or not
  ///
  /// E.G disable exit animation for main route
  BoolFunction checkEntAnimationEnabled,

  /// Function that checks whether to play exit animation or not
  ///
  /// E.G disable exit animation for particular route pushes
  BoolFunction checkExitAnimationEnabled,

  /// Begin offset for enter animation
  ///
  /// Defaults to `const Offset(1.0, 0.0)`
  final Offset entBegin: const Offset(1.0, 0.0),

  /// End offset for enter animation
  ///
  /// Defaults to `Offset.zero`
  final Offset entEnd = Offset.zero,

  /// Begin offset for exit animation
  ///
  /// Defaults to `Offset.zero`
  final Offset exitBegin: Offset.zero,

  /// End offset for exit animation
  ///
  /// Defaults to `const Offset(-0.3, 0.0)`
  final Offset exitEnd: const Offset(-0.3, 0.0),

  /// A curve for enter animation
  ///
  /// Defaults to `Curves.linearToEaseOut`
  final Curve entCurve: Curves.linearToEaseOut,

  /// A curve for reverse enter animation
  ///
  /// Defaults to `Curves.easeInToLinear`
  final Curve entReverseCurve: Curves.easeInToLinear,

  /// A curve for exit animation
  ///
  /// Defaults to `Curves.linearToEaseOut`
  final Curve exitCurve: Curves.linearToEaseOut,

  /// A curve for reverse exit animation
  ///
  /// Defaults to `Curves.easeInToLinear`
  final Curve exitReverseCurve: Curves.easeInToLinear,

  /// A duration of transition
  ///
  /// Defaults to `const Duration(milliseconds: 430)`
  final Duration transitionDuration: const Duration(milliseconds: 430),

  /// Field to pass `RouteSettings`
  final RouteSettings settings,

  ///Whether the route obscures previous routes when the transition is complete.
  ///
  /// When an opaque route's entrance transition is complete, the routes behind the opaque route will not be built to save resources.
  ///
  /// Copied from `TransitionRoute`.
  ///
  /// Defaults to true
  final bool opaque: true,

  /// Whether the route should remain in memory when it is inactive.
  ///
  /// If this is true, then the route is maintained, so that any futures it is holding from the next route will properly resolve when the next route pops. If this is not necessary, this can be set to false to allow the framework to entirely discard the route's widget hierarchy when it is not visible.
  ///
  /// The value of this getter should not change during the lifetime of the object. It is used by [createOverlayEntries], which is called by [install] near the beginning of the route lifecycle.
  ///
  /// Copied from `ModalRoute`.
  ///
  /// Defaults to false
  final bool maintainState: false,

  /// Whether to ignore touch events while enter animation
  ///
  /// Defaults to false
  final bool entIgnoreEvents: false,
}) {
  checkEntAnimationEnabled ??= () => true;
  checkExitAnimationEnabled ??= () => true;
  return PageRouteBuilder<T>(
      transitionDuration: Duration(milliseconds: 500),
      settings: settings,
      opaque: opaque,
      maintainState: maintainState,
      pageBuilder: (
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
      ) =>
          route,
      transitionsBuilder: (
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
        Widget child,
      ) {
        bool entEnabled = checkEntAnimationEnabled();
        bool exitEnabled = checkExitAnimationEnabled();

        return TurnableSlideTransition(
          enabled: entEnabled,
          position: Tween(begin: entBegin, end: entEnd).animate(CurvedAnimation(
              parent: animation,
              curve: entCurve,
              reverseCurve: entReverseCurve)),
          child: TurnableSlideTransition(
            enabled: exitEnabled,
            position: Tween(begin: exitBegin, end: exitEnd).animate(
                CurvedAnimation(
                    parent: secondaryAnimation,
                    curve: exitCurve,
                    reverseCurve: exitReverseCurve)),
            child: Container(
              foregroundDecoration: BoxDecoration(
                color: // Dim exit page from 0 to 0.9
                    Colors.black.withOpacity(
                        exitEnabled ? secondaryAnimation.value / 1.1 : 0),
              ),
              child: IgnorePointer(
                // Disable any touch events on fake exit route only while transitioning
                ignoring: entIgnoreEvents &&
                    (animation.status == AnimationStatus.forward ||
                        animation.status == AnimationStatus.reverse),
                child: child,
              ),
            ),
          ),
        );
      });
}

/// `SlideTransition` class, but with `enabled` parameter
class TurnableSlideTransition extends SlideTransition {
  TurnableSlideTransition(
      {Key key,
      @required Animation<Offset> position,
      bool transformHitTests: true,
      TextDirection textDirection,
      Widget child,
      this.enabled: true})
      : super(
          key: key,
          position: position,
          transformHitTests: transformHitTests,
          textDirection: textDirection,
          child: child,
        );

  /// If false, animation won't be played
  final bool enabled;

  @override
  Widget build(BuildContext context) {
    if (enabled) {
      Offset offset = position.value;
      if (textDirection == TextDirection.rtl)
        offset = Offset(-offset.dx, offset.dy);
      return FractionalTranslation(
        translation: offset,
        transformHitTests: transformHitTests,
        child: child,
      );
    }
    return child;
  }
}


Originalответ:

Следуя совету Marc , я добавил ScrollController к ListView.builder и извлек код, который создает список дорожек, в отдельный метод, чтобы иметь возможность создать его поддельную копию.

Но, конечно, такое решение может привести к некоторым проблемам с производительностью, поскольку оно в любом случае создает новый ListView экземпляр

здесь был удален код


UPD1:

Как я изначально предполагал, простое использование списка приведет к огромной производительности по мере увеличения длины списка.

Чтобы исправить это, вы должны забыть о списке и использовать ScrollablePositinedList, который еще не доступен в самой библиотеке флаттера, но присутствует в репозитории флаттеров виджетов Google . Этот виджет позволяет вам переходить к элементу в списке без каких-либо проблем перфоманса (на самом деле, если вы посмотрите исходный код, он вообще не использует ListView внутри него). ИМХО, это идеальное решение и лучшее решение для перехода по списку на данный момент, и я надеюсь, что команда flutter добавит этот виджет в свою библиотеку в будущем.

Так что вам нужно скопировать / установить его в проектзатем выполните следующие шаги:

  1. Expose frontScrollController свойство состояния ScrollablePositinedList, есть также backScrollController, но front - главный контроллер прокрутки в этом виджете, если я правильно понял, потому чтодля меня смещение назад всегда равно 0.
  2. Далее рассмотрим, сколько места занимает отдельный элемент вашего списка
  3. Создать функцию, которая обрабатывает открытие нового маршрута
bool didTapDrawerTile = false;

 Future<void> _handleClickSettings() async {
    if (!didTapDrawerTile) {
      setState(() {
// Make sure that user won't be able to click drawer twice
        didTapDrawerTile = true;
      });
      Navigator.pop(context);
      await Future.delayed(Duration(
          milliseconds: 246)); // Default drawer close time
      await 
      Navigator.of(context).push(createSettingsRoute(_buildTracks(true)));
      setState(() {
        didTapDrawerTile = false;
      });
    }
  }
Создать функцию, которая создает содержимое вашей страницы
static final GlobalKey trackListGlobalKey = GlobalKey();

Widget _buildTracks([bool isFake = false]) {
     var indexOffset;
     var additionalScrollOffset ;
     if (isFake) {
// Stop possible scrolling
     listScrollController.jumpTo(listScrollController.offset);

// Calc init offsets

// Index offset to fake list (jumps to index in list)
// In my case tile is dense, so its height is 64
      indexOffset = listScrollController.offset ~/ 64;

// Additional offset to list (specified `initialScrollIndex`, the `frontScrollController` offset anyways will be zero, so we just add additional offset in range of 0 to <yourTileHeight> - 1)
      additionalScrollOffset = listScrollController.offset % 64;
    }
    return IgnorePointer(

// Just to be sure that our widgets won't dispose after transition add global key
      key: isFake ? null : trackListGlobalKey,

// Disable entire fake touch events
      ignoring: didTapDrawerTile,

      child: Scaffold(
        drawer: Theme(
          data: Theme.of(context).copyWith(
            canvasColor:
                Color(0xff070707), //This will change the drawer background
          ),
          child: Drawer(
            child: ListView(
              physics: NeverScrollableScrollPhysics(),
              // Important: Remove any padding from the ListView.
              padding: EdgeInsets.zero,
              children: <Widget>[
                Container(
                  // height: 100.0,
                  padding: const EdgeInsets.only(
                      left: 15.0, top: 40.0, bottom: 20.0),
                  child: Text('Меню', style: TextStyle(fontSize: 35.0)),
                ),
                ListTile(
                    title: Text('Настройки',
                        style: TextStyle(
                            fontSize: 17.0, color: Colors.deepPurple.shade300)),

                  // Function that opens new route
                    onTap: _handleClickSettings

                ),
              ],
            ),
          ),
        ),
        appBar: AppBar(
          // automaticallyImplyLeading: false,
          leading: DrawerButton(),
          actions: <Widget>[
            IconButton(
              icon: Icon(Icons.sort),
              onPressed: () {
                _showSortModal();
              },
            ),
          ],
          titleSpacing: 0.0,
          title: Padding(
            padding: const EdgeInsets.only(left: 0.0),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(10),
              child: GestureDetector(
                onTap: _showSearch,
                child: FractionallySizedBox(
                  // heightFactor: 1,
                  widthFactor: 1,
                  child: Container(
                    padding: const EdgeInsets.only(
                        left: 12.0, top: 10.0, bottom: 10.0),
                    decoration: BoxDecoration(
                      color: Colors.white.withOpacity(0.05),
                    ),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        Text(
                          'Поиск треков на устройстве',
                          style: TextStyle(
                              color: Theme.of(context).hintColor, fontSize: 17),
                        )
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
        body: Stack(
          children: <Widget>[
            Padding(
              padding: widget.bottomPadding,
              child: Container(
                child: CustomRefreshIndicator(
                  color: Colors.white,
                  strokeWidth: 2.5,
                  key: isFake ? null : _refreshIndicatorKey,
                  onRefresh: _refreshHandler,
                  child: SingleTouchRecognizerWidget(
                    child: Container(
                      child: ScrollablePositionedList.builder(

// Pass index offset
                        initialScrollIndex: isFake ? indexOffset : 0,

// Pass additional offset
                        frontScrollController: isFake
                            ? ScrollController(
                                initialScrollOffset: additionalScrollOffset )
                            : listScrollController,
                        itemCount: PlaylistControl.globalPlaylist.length,
                        padding: EdgeInsets.only(bottom: 10, top: 0),
                        itemBuilder: (context, index) {
                          return StreamBuilder(
                              stream: PlaylistControl.onSongChange,
                              builder: (context, snapshot) {
                                return TrackTile(
                                  index,
                                  key: UniqueKey(),
                                  playing: index ==
                                      PlaylistControl.currentSongIndex(
                                          PlaylistType.global),
                                  additionalClickCallback: () {
                                    PlaylistControl.resetPlaylists();
                                  },
                                );
                              });
                        },
                      ),
                    ),
                  ),
                ),
              ),
            ),
            BottomTrackPanel(),
          ],
        ),
      ),
    );
  }

Я также изменил сам виджет перехода

     import 'package:flutter/material.dart';

/// Creates cupertino-like route transition, where new route pushes old from right to left
     class SlideStackRightRoute extends PageRouteBuilder {
     final Widget enterPage;
     final Widget exitPage;
     static var exBegin = Offset(0.0, 0.0);
     static var exEnd = Offset(-0.3, 0.0);
     static var entBegin = Offset(1.0, 0.0);
     static var entEnd = Offset.zero;
     static var curveIn = Curves.linearToEaseOut;
     static var curveOut = Curves.easeInToLinear;

    SlideStackRightRoute({@required this.exitPage, @required this.enterPage})
      : super(
          transitionDuration: Duration(milliseconds: 1400),
          maintainState: true,
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) =>
              enterPage,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) =>
              Stack(
            children: <Widget>[
              SlideTransition(
                position: Tween(begin: exBegin, end: exEnd)
                    .chain(CurveTween(curve: curveIn))
                    .chain(CurveTween(curve: curveOut))
                    .animate(animation),
                child: Container(
                  foregroundDecoration: BoxDecoration(
                    color: Colors.black.withOpacity(animation.value / 1.1),
                  ),
                  child: IgnorePointer(

// Disable any touch events on fake exit route
                    ignoring: true,
                    child: exitPage,
                  ),
                ),
              ),
              SlideTransition(
                position: Tween(begin: entBegin, end: entEnd)
                    .chain(CurveTween(curve: curveIn))
                    .chain(CurveTween(curve: curveOut))
                    .animate(animation),
                child: IgnorePointer(

// Disable any touch events on fake exit route only while transitioning
                  ignoring: animation.status != AnimationStatus.completed,
                  child: enterPage,
                ),
              )
            ],
          ),
        );
    }
...