Skip to content

Latest commit

 

History

History
657 lines (578 loc) · 25.1 KB

6.六、常用滚动组件.md

File metadata and controls

657 lines (578 loc) · 25.1 KB

在Flutter中,Container widget 本身并不提供滚动功能。如果想创建一个可滑动的列表,应该使用专门用于滚动内容的widgets,如 ListViewCustomScrollView

几乎所有的可滚动组件在构造时都能指定 scrollDirection(滑动的主轴)、reverse(滑动方向是否反向)、controllerphysics 、cacheExtent ,这些属性最终会透传给对应的 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性。

SingleChildScrollView

SingleChildScrollView类似于Android和小程序中的ScrollView,它只能接收一个子组件:

SingleChildScrollView({
  this.scrollDirection = Axis.vertical, //滚动方向,默认是垂直方向
  this.reverse = false, 
  this.padding, 
  bool primary, 
  this.physics, 
  this.controller,
  this.child,
})

SingleChildScrollView并没有一个默认的高度。它会尽可能地适应其父级widget的约束。如果SingleChildScrollView的父级没有提供明确的大小约束,SingleChildScrollView将会尽可能地大以适应所有的子widget。

通常SingleChildScrollView只应在期望的内容不会超过屏幕太多时使用,这是因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型,所以如果预计视口可能包含超出屏幕尺寸太多的内容时,那么使用SingleChildScrollView将会非常昂贵(性能差),此时应该使用一些支持Sliver延迟加载的可滚动组件,如ListView

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(MaterialApp(
    home: Scaffold(
        appBar: AppBar(
          title: Text('Scrollable 示例'),
        ),
        body: Container(
          color: Colors.blue,
          width: double.infinity, // 撑满屏幕宽度
          child: SizedBox(
            height: 200, // 为SingleChildScrollView设置一个固定的高度
            child: SingleChildScrollView(
              child: Column(
                children: List.generate(30, (index) => Text('Item $index')),
              ),
            ),
          ),
        )
        ),
  ));
}

没有滚动条,可以使用Scrollbar控件将SingleChildScrollView进行包裹来实现

ListView

ListView是用于创建滚动列表的一个非常常用且强大的 widget。它可以水平或垂直显示其子 widget 列表,并且在列表项过多时可以高效地滚动。ListView 适用于显示一系列具有相同类型的数据项,例如文本列表、图片列表等。

ListView({
  ...  
  //可滚动widget公共参数
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController? controller,
  bool? primary,
  ScrollPhysics? physics,
  EdgeInsetsGeometry? padding,
  
  //ListView各个构造函数的共同参数  
  double? itemExtent,
  Widget? prototypeItem, //列表项原型,后面解释
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double? cacheExtent, // 预渲染区域长度
    
  //子widget列表
  List<Widget> children = const <Widget>[],
})

ListView 的构造函数:

  • 默认构造函数:直接传递一个 children 列表。
  • ListView.builder:使用 ItemBuilder 函数动态创建子 widget。适用于数据项较多或数据项需要按需生成时,可以提高性能。
  • ListView.separated:类似于 ListView.builder,但可以在每两个列表项之间添加分隔符。
  • ListView.custom:使用自定义的 SliverChildDelegate 来创建列表,提供了最高的自定义灵活性。

ListView各个构造函数的参数:

属性 类型 描述
itemExtent double? 可选地强制子项在滚动方向上具有给定的范围。当所有子项高度相同时,指定这个可以提高滚动性能。
prototypeItem Widget? 用于确定每个子项大小的widget原型。这个原型项不会被绘制,但会被测量以获取子项的默认大小。
shrinkWrap bool 决定视图是否应该尽可能小地包裹其内容的高度。
addAutomaticKeepAlives bool 是否将AutomaticKeepAlive包装在每个子项周围。
addRepaintBoundaries bool 是否将RepaintBoundary包装在每个子项周围。
cacheExtent double? 预渲染的区域长度,可以提前渲染位于当前视口之外但可能很快滚动到视口中的子项。
公共参数:
  • scrollDirection:滚动方向,默认为垂直方向 (Axis.vertical),也可以设置为水平方向 (Axis.horizontal)。
  • padding:列表的内边距。
  • reverse:是否反转列表的显示方向,例如在聊天应用中,可能需要反转列表,使最新消息显示在底部。
  • controller:控制列表滚动的 ScrollController
  • physics:滚动物理效果,如 BouncingScrollPhysicsClampingScrollPhysics 等。

ListView 默认构造函数

默认示例:

body: Container(width: double.infinity, height: 100, color: Colors.blue, child: ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('I\'m dedicating every day to you'),
    const Text('Domestic life was never quite my style'),
    const Text('When you smile, you knock me out, I fall apart'),
    const Text('And I thought I was so smart'),
  ],
)), //

ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})

ListView.builder(
  itemCount: 100, // 列表项的数量
  itemExtent: 50.0, //强制高度为50.0
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text('列表项 $index'));
  },
)

ListView.separated

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器

class ListView3 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //下划线widget预定义以供复用。  
    Widget divider1=Divider(color: Colors.blue,);
    Widget divider2=Divider(color: Colors.green);
    return ListView.separated(
      itemCount: 100,
      //列表项构造器
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      },
      //分割器构造器
      separatorBuilder: (BuildContext context, int index) {
        return index%2==0?divider1:divider2;
      },
    );
  }
}

ListView 原理

ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:

  1. ListView 中的列表项组件都是 RenderBox,并不是 Sliver, 这个一定要注意。
  2. 一个 ListView 中只有一个Sliver,对列表项进行按需加载的逻辑是 Sliver 中实现的。
  3. ListView 的 Sliver 默认是 SliverList,如果指定了 itemExtent ,则会使用 SliverFixedExtentList;如果 prototypeItem 属性不为空,则会使用 SliverPrototypeExtentList,无论是是哪个,都实现了子组件的按需加载模型。

滚动监听及控制

ScrollController

ScrollController 在 Flutter 中用于控制可滚动的 widget,例如 ListViewGridViewSingleChildScrollView 等。它可以用来控制滚动位置,执行动画滚动,监听滚动事件等。

如何使用

  1. 先创建一个 ScrollController 并将其传递给可滚动的 widget:
ScrollController _scrollController = ScrollController();
  1. 将 ScrollController 与滚动视图关联
ListView(
  controller: _scrollController,
  children: [...],
)
  1. 使用 ScrollController,可以使用 ScrollController 做一些操作,如:
    • 查询滚动位置:_scrollController.offset
    • 滚动到特定位置:_scrollController.jumpTo(value)
    • 动画滚动到特定位置:_scrollController.animateTo(value, duration: Duration, curve: Curve)
    • 监听滚动事件:通过添加监听器 _scrollController.addListener(() { /* 监听滚动事件 */ });
    • 控制滚动到顶部或底部:_scrollController.jumpTo(_scrollController.position.minScrollExtent)_scrollController.jumpTo(_scrollController.position.maxScrollExtent)

示例

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(() {
      if (_scrollController.position.atEdge) {
        if (_scrollController.position.pixels == 0) {
          print('At the top');
        } else {
          print('At the bottom');
        }
      }
    });
  }

  @override
  void dispose() {
    _scrollController.dispose(); // Remember to dispose the controller
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ScrollController 示例'),
      ),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: 30,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(title: Text('列表项 $index'));
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.arrow_upward),
        onPressed: () {
          // 滚动到顶部
          _scrollController.animateTo(
            0,
            duration: Duration(seconds: 1),
            curve: Curves.easeOut,
          );
        },
      ),
    );
  }
}

NotificationListener

子Widget可以通过发送通知(Notification)与父(包括祖先)Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:

  1. NotificationListener可以在可滚动组件到widget树根之间任意位置监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

AnimatedList

AnimatedList 和 ListView 的功能大体相似,不同的是, AnimatedList 可以在列表中插入或删除节点时执行一个动画,在需要添加或删除列表项的场景中会提高用户体验。

如何使用

  1. 创建一个 GlobalKey<AnimatedListState> 来与 AnimatedList 的状态关联。
  2. 使用 AnimatedList widget,并通过 key 属性将其与上面创建的 GlobalKey 关联。
  3. 通过调用 AnimatedListStateinsertItemremoveItem 方法来控制列表项的动画插入和移除。
  4. itemBuilder 函数中返回列表项的widget,并处理动画。
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
  List<String> _items = ['Item 1', 'Item 2', 'Item 3'];

  void _addItem() {
    final int index = _items.length;
    _items.add('Item ${_items.length + 1}');
    _listKey.currentState?.insertItem(index);
  }

  void _removeItem(int index) {
    final String removedItem = _items.removeAt(index);
    _listKey.currentState?.removeItem(
      index,
      (context, animation) => _buildItem(removedItem, animation),
    );
  }

  Widget _buildItem(String item, Animation<double> animation) {
    return SizeTransition(
      sizeFactor: animation,
      child: ListTile(
        title: Text(item),
        onTap: () => _removeItem(_items.indexOf(item)),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('AnimatedList 示例'),
      ),
      body: AnimatedList(
        key: _listKey,
        initialItemCount: _items.length,
        itemBuilder: (context, index, animation) {
          return _buildItem(_items[index], animation);
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: _addItem,
      ),
    );
  }
}

GridView

GridView 是 Flutter 中用于创建网格布局的一个滚动widget。它可以创建多列布局,并且每列中的项可以是任意的widget。GridView 对于构建产品列表、图库、键盘、九宫格等布局非常有用。

构造函数

  • GridView:最基本的构造函数,需要显式指定children列表。
  • GridView.count:可以指定列数的构造函数,自动为你创建网格。
  • GridView.builder:通过itemBuilder动态创建子项的构造函数,适用于具有大量(或无限)子项的网格,因为它只构建那些实际可见的子项。
  • GridView.custom:使用自定义的SliverGridDelegateSliverChildDelegate
  • GridView.extent:可以指定最大子项宽度的构造函数,网格列数将根据屏幕宽度和子项最大宽度动态计算。

主要属性

  • gridDelegate:控制网格布局的策略。通常使用SliverGridDelegateWithFixedCrossAxisCount(固定列数)或SliverGridDelegateWithMaxCrossAxisExtent(最大子项宽度)。
  • children:静态子项列表。
  • itemBuilder:动态构建子项的函数。
  • scrollDirection:滚动方向,默认垂直滚动。
  • crossAxisCount:列数(GridView.count构造函数),此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • maxCrossAxisExtent:最大子项宽度(GridView.extent构造函数)。
GridView.count(
  crossAxisCount: 3, // 每行三列
  children: List.generate(20, (index) {
    return Center(
      child: Text('Item $index'),
    );
  }),
)

GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3, // 每行三列
  ),
  itemCount: 100, // 100个子项
  itemBuilder: (BuildContext context, int index) {
    return Center(
      child: Text('Item $index'),
    );
  },
)

SliverGridDelegateWithFixedCrossAxisCount

该子类实现了一个横轴为固定数量子元素的layout算法,其构造函数为:

SliverGridDelegateWithFixedCrossAxisCount({
  @required double crossAxisCount, 
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})
  • crossAxisCount:横轴子元素的数量。此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • mainAxisSpacing:主轴方向的间距。
  • crossAxisSpacing:横轴方向子元素的间距。
  • childAspectRatio:子元素在横轴长度和主轴长度的比例。由于crossAxisCount指定后,子元素横轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。

SliverGridDelegateWithMaxCrossAxisExtent

该子类实现了一个横轴子元素为固定最大长度的layout算法,其构造函数为:

SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent,
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})

maxCrossAxisExtent为子元素在横轴上的最大长度,之所以是“最大”长度,是因为横轴方向每个子元素的长度仍然是等分的,举个例子,如果ViewPort的横轴长度是450,那么当maxCrossAxisExtent的值在区间(450/4,450/3)内的话,子元素最终实际长度都为112.5,而childAspectRatio所指的子元素横轴和主轴的长度比为最终的长度比。其他参数和SliverGridDelegateWithFixedCrossAxisCount相同。

示例 justify-content: space-betweenspace-around

通过调整网格内边距(padding)和网格间距(crossAxisSpacingmainAxisSpacing)来模拟这种效果。

crossAxisSpacingmainAxisSpacing分别控制列之间和行之间的间距。如果想在网格的两侧有空隙,可以在GridViewpadding属性中添加水平方向上的内边距

body: GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3, // 每行三列
    crossAxisSpacing: 8.0, // 列间距
    mainAxisSpacing: 8.0, // 行间距
  ),
  padding: EdgeInsets.symmetric(horizontal: 8.0), // 水平内边距,两侧空隙--注释该行实现space-between
  itemCount: 20, // 20个子项
  itemBuilder: (BuildContext context, int index) {
    return Container(
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: Colors.blue,
        borderRadius: BorderRadius.circular(8.0),
      ),
      child: Text('Item $index'),
    );
  },
)

PageView - 整屏滚动

PageView 通常用于构建引导页、图片轮播、使用滑动来切换不同的视图等场景。

PageView 是一个可滚动的列表,允许水平滚动或垂直滚动来切换页面。每个子widget通常是使用整个屏幕的页面。它用于实现滑动切换页面的效果,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,类似于 Android 中的 ViewPager 或 iOS 中的 UIPageViewController。

PageView({
  Key? key,
  this.scrollDirection = Axis.horizontal, // 滑动方向
  this.reverse = false,
  PageController? controller,
  this.physics,
  List<Widget> children = const <Widget>[],
  this.onPageChanged,
  
  //每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面
  this.pageSnapping = true,
  //主要是配合辅助功能用的,后面解释
  this.allowImplicitScrolling = false,
  //后面解释
  this.padEnds = true,
})
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    home: MyApp(),
  ));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Scrollable 示例'),
        ),
        body: MyHomePage());
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    var children = <Widget>[];
    // 生成 6 个 Tab 页
    for (int i = 0; i < 6; ++i) {
      children.add(Page(text: '$i'));
    }

    return PageView(
      // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
      children: children,
    );
  }
}

class Page extends StatefulWidget {
  const Page({Key? key, required this.text}) : super(key: key);

  final String text;

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

class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}

页面缓存

PageView 创建的每个页面默认会在不可见时从widget树中卸载,然后在滚动回来时重新创建。如果你想要PageView在页面切换时缓存页面,可以使用AutomaticKeepAliveClientMixin,这样当页面滑出视窗时,它的状态仍然会被保留

class MyPage extends StatefulWidget {
  @override
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true; // 是否希望保持页面存活

  @override
  Widget build(BuildContext context) {
    super.build(context); // 需要调用super.build
    // 构建页面内容
  }
}

TabBarView

TabBarView 在 Flutter 中是与 TabBar 一起使用的,用于创建带有对应标签页的滑动视图。每个 TabBarView 的子widget通常对应一个 TabBar 项,用户可以通过点击 TabBar 项或者水平滑动 TabBarView 来切换页面。

TabBarView 的每个子页面都是懒加载的,这意味着页面会在它们即将显示在视图中时才会被创建。因为TabBarView 内部封装了 PageView,如果要缓存页面,可以参考 6.8 可滚动组件子项缓存 | 《Flutter实战·第二版》

TabBarView 默认不会保持其页面的状态。如果你的页面需要保持状态(例如滚动位置),你可以将每个页面的根widget包装在 AutomaticKeepAlive widget 中

TabBarView

TabBarView({
  Key? key,
  required this.children, // tab 页 与 `TabBar` 的标签数量相匹配的 widget 列表
  this.controller, // TabController,用于协调 `TabBar` 和 `TabBarView` 之间的选项卡选择。如果你使用 `DefaultTabController`,则不需要手动提供这个
  this.physics, // 滚动物理效果,如 `BouncingScrollPhysics`、`ClampingScrollPhysics` 等。
  this.dragStartBehavior = DragStartBehavior.start,
}) 

TabBar

TabBar 通常位于 AppBar 的底部,它也可以接收一个 TabController ,如果需要和 TabBarView 联动, TabBar 和 TabBarView 使用同一个 TabController 即可,注意,联动时 TabBar 和 TabBarView 的孩子数量需要一致。如果没有指定 controller,则会在组件树中向上查找并使用最近的一个 DefaultTabController 。另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar, tab 可以是任何 Widget,不过Material 组件库中已经实现了一个 Tab 组件,我们一般都会直接使用它。

const TabBar({
  Key? key,
  required this.tabs, // 具体的 Tabs,需要我们创建
  this.controller,
  this.isScrollable = false, // 是否可以滑动
  this.padding,
  this.indicatorColor,// 指示器颜色,默认是高度为2的一条下划线
  this.automaticIndicatorColorAdjustment = true,
  this.indicatorWeight = 2.0,// 指示器高度
  this.indicatorPadding = EdgeInsets.zero, //指示器padding
  this.indicator, // 指示器
  this.indicatorSize, // 指示器长度,有两个可选值,一个tab的长度,一个是label长度
  this.labelColor, 
  this.labelStyle,
  this.labelPadding,
  this.unselectedLabelColor,
  this.unselectedLabelStyle,
  this.mouseCursor,
  this.onTap,
  ...
}) 

const Tab({
  Key? key,
  this.text, //文本
  this.icon, // 图标
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定义 widget
})

使用 TabController

创建了TabController之后,需要将它同时传递给TabBarTabBarView

_tabController = TabController(vsync: this, length: 3); // 3个标签页

TabBar(
  controller: _tabController,
  tabs: myTabs,
),
TabBarView(
  controller: _tabController,
  children: myTabViews,
),

简单示例

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: DefaultTabController(
        length: 2,
        child: Scaffold(
          appBar: AppBar(
            bottom: TabBar(
              tabs: [
                Tab(icon: Icon(Icons.directions_car)),
                Tab(icon: Icon(Icons.directions_transit)),
              ],
            ),
            title: Text('Tabs Demo'),
          ),
          body: TabBarView(
            children: [
              Icon(Icons.directions_car),
              Icon(Icons.directions_transit),
            ],
          ),
        ),
      ),
    );
  }
}

CustomScrollView

目前对于还未入门的我来说有点超纲,且目前需求可能使用率不高,mark一下先:6.10 CustomScrollView 和 Slivers | 《Flutter实战·第二版》