过渡到另一条路线时,如何使ListView保留其滚动?

时间:2019-10-13 01:07:37

标签: listview flutter scroll routes transition

我想在我的flutter应用程序中完成向右滑动的转换。 问题在于路由转换有点会创建我要从其转换的页面的新实例,因此ListView滚动重置。

See a video

这是包含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,
              )
            ],
          ),
        );
}

2 个答案:

答案 0 :(得分:0)

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函数

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


原始答案:

按照Marc的建议,我在ScrollController上添加了ListView.builder,并将创建跟踪列表的代码提取到单独的方法中,以便能够创建其伪造副本。

但是,这种解决方案当然会在创建新的ListView实例时可能导致一些性能问题

此处已删除代码


UPD1:

正如我最初所预期的,随着列表长度的增加,仅使用列表将导致巨大的性能。

要解决此问题,您必须忘记列表并使用ScrollablePositinedList,该列表在flutter的库本身中尚不可用,但在google's flutter widgets repository中存在。这个小部件可让您跳到列表中的元素而不会出现性能问题(实际上,如果您查看源代码,则根本不使用ListView)。恕我直言,这是一个完美的解决方案,也是暂时跳过列表的最佳解决方案,我希望flutter团队将来能够将此小部件添加到他们的库中。

因此,您必须将其复制/安装到项目中,然后执行后续步骤:

  1. 公开frontScrollController状态的ScrollablePositinedList属性,还有backScrollController,但是如果我正确理解,front是此小部件中的主要滚动控制器,因为back的偏移量始终相等对我来说是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;
      });
    }
  }
  1. 创建函数,用于构建页面内容
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,
                ),
              )
            ],
          ),
        );
    }

答案 1 :(得分:0)

我解决此问题的方法是将偏移量保存到page类中的静态变量中,然后将控制器添加到listview构建器中。例如

static double _previousScrollOffset = 0;
final ScrollController _controller = new ScrollController(initialScrollOffset: _previousScrollOffset);

然后,每当导航到新路线时,我都会保存状态。例如

onTap: () {
_previousScrollOffset = _controller.offset;
Navigator.pushNamed<bool>(
    context, ROUTE_ADD_HOUSING + "/" + housing.uuid);   
},