在Flutter中将PageView嵌入ListView的实现方法

1,055 阅读4分钟

在开发Flutter应用时,有时候我们需要在一个列表中引用一个可滑动的页面视图(PageView)。这种情况经常出现在需要在一个列表项中展示多个页面内容的场景中。在本文中,我们将探讨如何在ListView中嵌入PageView。

将PageView嵌入ListView是一项关键的任务,而实现这一任务离不开一个重要的自定义组件——ExpandablePageView。正是通过ExpandablePageView组件,我们能够实现在ListView中嵌入PageView的功能。

ExpandablePageView组件在这个场景中扮演着至关重要的角色。它巧妙地结合了ListView和PageView的特性,实现了PageView无缝嵌入到ListView中,并且能够根据页面内容的高度自适应调整列表项的高度。

核心思想是ExpandablePageView利用SizeReportingWidget来获取每个页面内容的实际高度,并通过动态调整高度的方式实现平滑的过渡效果。通过将SizeReportingWidget包裹在OverflowBox中,避免了父级组件对子组件尺寸的限制,从而确保了页面内容的高度能够正确计算。

借助ExpandablePageView组件,我们能够灵活地实现复杂的界面布局,将PageView作为ListView的一部分展示。这种灵活的实现方式为开发人员提供了更多自由度,使得构建具有自适应高度的界面变得更加简单和便捷。

接下来,我们将深入探讨ExpandablePageView组件的具体实现细节,以及它是如何实现PageView在ListView中的完美嵌入。让我们继续进入下一部分内容,探索这个关键组件的奥秘。

下面我们先来看一下要实现的效果是什么样的:

image.png

image.png

通过上面两个效果图可以看出一个卡片被当作ListView的item进行使用,同时处理进度操作日志页面也展现了不同的内容,这样就要求对高度进行计算。

下面就让我们一起来看看具体的代码实现:

ListPageDemo

class ListPageDemo extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => ListPageDemoState();
}

class ListPageDemoState extends State<ListPageDemo> {
  int _currentIndex = 0;

  GlobalKey<WorkRefreshStateMixin> globalRefresh =
      GlobalKey<WorkRefreshStateMixin>();
  GlobalKey<WorkLogStateMixin> workKeyMixin = GlobalKey<WorkLogStateMixin>();
  GlobalKey globalTabKey = GlobalKey();
  @override
  void initState() {
    super.initState();
  }

  List<TabInfo> _getTabList() {
    TabInfo tabInfoOne = TabInfo(
        title: "处理进度", tabId: 0, isSelected: _currentIndex == 0 ? 1 : 0);
    TabInfo tabInfoTwo = TabInfo(
        title: "操作日志", tabId: 1, isSelected: _currentIndex == 1 ? 1 : 0);

    return [tabInfoOne, tabInfoTwo];
  }

  void _onTab(
    TabInfo tabInfo,
    GlobalKey<WorkLogStateMixin> workKeyMixin,
  ) {
    workKeyMixin.currentState?.onMixinTabChanged(tabInfo.tabId ?? 0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("ListPageDemo"),
      ),
      body: ListView.builder(
        itemBuilder: (context, index) {
          if (index == 19) {
            return _renderLogView(
              globalTabKey,
              globalRefresh,
              workKeyMixin,
            );
          }
          return _renderItem(index);
        },
        itemCount: 20,
      ),
    );

    //  _renderLogView(
    //       globalTabKey,
    //       globalRefresh,
    //       workKeyMixin,
    //     )
  }

  Widget _renderItem(int index) {
    return Container(
      padding: EdgeInsets.all(
        24.rpx,
      ),
      child: Text(
        "item $index",
        style: TextStyle(
          color: Color(0xff373A3E),
          fontSize: 28.rpx,
        ),
      ),
    );
  }

  Widget _renderLogView(
    GlobalKey globalTabKey,
    GlobalKey<WorkRefreshStateMixin> globalRefresh,
    GlobalKey<WorkLogStateMixin> workKeyMixin,
  ) {
    return Card(
      key: globalTabKey,
      margin: EdgeInsets.only(
          left: 24.rpx, bottom: 24.rpx, right: 24.rpx, top: 0.rpx),
      elevation: 0,
      shape:
          RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.rpx)),
      // color: Color(0xffF6F7F9),
      child: Container(
        decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.rpx)),
        // padding: EdgeInsets.all(5.rpx),
        child: Column(
          children: [
            TabCustomBar(
              backgroundColor: Color(0xffF6F7F9),
              indicatorColor: Color(0xff2C83F5),
              fontSize: 28.rpx,
              tabHeight: 90.rpx,
              sourceList: _getTabList(),
              isExpanded: false,
              borderRadius: BorderRadius.circular(12.rpx),
              onTap: (tabInfo) {
                _onTab(tabInfo, workKeyMixin);
              },
            ),
            ExpandablePageView(
              key: workKeyMixin,
              children: [
                HandleProcessView(
                  key: globalRefresh,
                  tabKeyMixin: workKeyMixin,
                  ticketId: 0,
                ),
                HandleLoggerView(
                  ticketId: 1,
                  tabKeyMixin: workKeyMixin,
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

_renderItem方法用于渲染普通的列表项,每个列表项都是一个带有文本的容器。

_renderLogView方法渲染了一个卡片,其中包含了TabCustomBarExpandablePageViewTabCustomBar是一个自定义的选项卡栏,根据当前选中的选项卡更新_currentIndex的值。ExpandablePageView是一个可展开的PageView,根据选项卡的切换,显示不同的视图.

TabCustomBar 实现比较简单:

class TabCustomBar extends StatelessWidget implements PreferredSizeWidget {
  List<TabInfo>? sourceList = [];
  double? tabHeight = 60.rpx;

  ValueChanged<TabInfo>? onTap;
  Color? indicatorColor;
  Color? normalTextColor;
  Color? normalIndicator;
  Color? backgroundColor;
  double? fontSize;
  BorderRadiusGeometry? borderRadius;
  bool? isExpanded;
  TabCustomBar({
    this.tabHeight,
    this.sourceList,
    this.onTap,
    this.indicatorColor,
    this.normalTextColor,
    this.normalIndicator,
    this.backgroundColor,
    this.fontSize,
    this.borderRadius,
    this.isExpanded,
  });

  @override
  Widget build(BuildContext context) {
    return TabCustomView(
      tabHeight: tabHeight,
      onTap: onTap,
      sourceList: sourceList,
      indicatorColor: indicatorColor,
      normalIndicator: normalIndicator,
      normalTextColor: normalTextColor,
      backgroundColor: backgroundColor,
      fontSize: fontSize,
      borderRadius: borderRadius,
      isExpanded: isExpanded,
    );
  }

  @override
  Size get preferredSize => Size.fromHeight(tabHeight ?? kToolbarHeight);
}
class TabCustomView extends StatefulWidget {
  List<TabInfo>? sourceList = [];
  double? tabHeight = 60.rpx;

  ValueChanged<TabInfo>? onTap;
  Color? indicatorColor;
  Color? normalTextColor;
  Color? normalIndicator;
  Color? backgroundColor;
  double? fontSize;
  BorderRadiusGeometry? borderRadius;
  bool? isExpanded;
  TabCustomView({
    this.tabHeight,
    this.sourceList,
    this.onTap,
    this.indicatorColor,
    this.normalTextColor,
    this.normalIndicator,
    this.backgroundColor,
    this.fontSize,
    this.borderRadius,
    this.isExpanded = true,
  });

  @override
  State<StatefulWidget> createState() => TabCustomViewState();
}

///
class TabCustomViewState extends State<TabCustomView> {
  Color? _indicatorColor;
  Color? _normalTextColor;
  Color? _normalIndicator;
  @override
  void initState() {
    super.initState();
  }

  void setDefaultColor() {
    _indicatorColor = widget.indicatorColor ?? const Color(0xff1077FE);
    _normalTextColor = widget.normalIndicator ?? const Color(0xff404B55);
    _normalIndicator = widget.normalIndicator ?? Colors.transparent;
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    setDefaultColor();
    return Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.white,
              widget.backgroundColor ?? Colors.white,
            ],
          ),
          borderRadius:
              widget.borderRadius ?? BorderRadius.all(Radius.circular(0.rpx)),
        ),
        // color: widget.backgroundColor ?? theme.colorScheme.pageBackgroundColor,
        height: widget.tabHeight,
        child: Row(
          children: [
            ..._getChildren(context, theme)!,
            if (widget.isExpanded == false)
              Expanded(flex: 2, child: Container())
          ],
        ));
  }

  ///
  List<Widget>? _getChildren(BuildContext context, ThemeData theme) {
    return widget.sourceList
        ?.map((tabInfo) => _getItem(
              context,
              theme,
              tabInfo,
            ))
        .toList();
  }

  ///onTap
  void _onTap(TabInfo tabInfo) {
    _changedTab(tabInfo);
    widget.onTap!(tabInfo);
  }

  ///changed tab state
  void _changedTab(tabInfo) {
    ///
    int? tabId = tabInfo.tabId;
    widget.sourceList!.forEach((info) {
      if (tabId == info.tabId) {
        info.isSelected = 1;
      } else {
        info.isSelected = 0;
      }
    });
    print('${widget.sourceList}');
    setState(() {});
  }

  ///
  Widget _getItem(BuildContext context, ThemeData theme, TabInfo tabInfo) {
    return Expanded(
      flex: 1,
      child: Material(
        color: Colors.transparent,
        // color: theme.colorScheme.white,

        child: InkWell(
          child: Container(
            padding: EdgeInsets.only(top: 25.rpx),
            child: Column(
              children: [
                Text(
                  '${tabInfo.title}',
                  style: TextStyle(
                      fontSize: widget.fontSize ?? 32.rpx,
                      fontWeight: tabInfo.isSelected == 1
                          ? FontWeight.w600
                          : FontWeight.normal,
                      color: tabInfo.isSelected == 1
                          ? _indicatorColor
                          : _normalTextColor),
                ),
                Container(
                  padding: EdgeInsets.all(0.rpx),
                  alignment: Alignment.bottomCenter,
                  margin: EdgeInsets.only(top: 20.rpx),
                  width: 60.rpx,
                  height: 5.rpx,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.all(Radius.circular(15.rpx)),
                    // ignore: unrelated_type_equality_checks
                    color: tabInfo.isSelected == 1
                        ? _indicatorColor
                        : _normalIndicator,
                    shape: BoxShape.rectangle,
                  ),
                ),
              ],
            ),
          ),
          onTap: () {
            ///
            _onTap(tabInfo);
          },
        ),
      ),
    );
  }
}

///
class TabInfo {
  String? title;
  int? tabId;
  int? isSelected;

  TabInfo({this.title, this.tabId, this.isSelected});

  @override
  String toString() {
    return 'TabInfo{title: $title, tabId: $tabId, isSelected: $isSelected}';
  }
}

接下来让我们一起来看一下ExpandablePageView组件:

class ExpandablePageView extends StatefulWidget {
  final List<Widget> children;
  final Function(int index)? onPageChanged;
  const ExpandablePageView({
    Key? key,
    required this.children,
    this.onPageChanged,
  }) : super(key: key);

  @override
  State<ExpandablePageView> createState() => _ExpandablePageViewState();
}

class _ExpandablePageViewState extends State<ExpandablePageView>
    with TickerProviderStateMixin, WorkLogStateMixin {
  late PageController _pageController;
  late List<double> _heights;
  int _currentPage = 0;

  double get _currentHeight => _heights[_currentPage];

  @override
  void initState() {
    _heights = widget.children.map((e) => 0.0).toList();
    super.initState();
    _pageController = PageController()
      ..addListener(() {
        final newPage = _pageController.page?.round() ?? 0;
        if (_currentPage != newPage) {
          setState(() => _currentPage = newPage);
        }
      });
  }

  @override
  void onMixinNotify() {
    if (mounted) setState(() {});
  }

  @override
  void onMixinTabChanged(int index) {
    // setState(() {
    //   _handleOnChange(index);
    // });
    _handleOnChange(index);
  }

  /// 当切换Tab时
  void _handleOnChange(
    int index,
  ) {
    ///
    _pageController.jumpToPage(index);
    // _currentIndex = index;
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder<double>(
      curve: Curves.easeInOutCubic,
      duration: const Duration(milliseconds: 100),
      tween: Tween<double>(begin: _heights[0], end: _currentHeight),
      builder: (context, value, child) => SizedBox(height: value, child: child),
      child: PageView(
        controller: _pageController,
        physics: const NeverScrollableScrollPhysics(),
        children: _sizeReportingChildren
            .asMap() //
            .map((index, child) => MapEntry(index, child))
            .values
            .toList(),
      ),
    );
  }

  List<Widget> get _sizeReportingChildren => widget.children
      .asMap() //
      .map(
        (index, child) => MapEntry(
          index,
          OverflowBox(
            //needed, so that parent won't impose its constraints on the children, thus skewing the measurement results.
            minHeight: 0,
            maxHeight: double.infinity,
            alignment: Alignment.topCenter,
            child: SizeReportingWidget(
              onSizeChange: (size) =>
                  setState(() => _heights[index] = size.height),
              child: Align(child: child),
            ),
          ),
        ),
      )
      .values
      .toList();
}

class SizeReportingWidget extends StatefulWidget {
  final Widget child;
  final ValueChanged<Size> onSizeChange;

  const SizeReportingWidget({
    Key? key,
    required this.child,
    required this.onSizeChange,
  }) : super(key: key);

  @override
  State<SizeReportingWidget> createState() => _SizeReportingWidgetState();
}

class _SizeReportingWidgetState extends State<SizeReportingWidget> {
  Size? _oldSize;

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
    return widget.child;
  }

  void _notifySize() {
    if (!mounted) {
      return;
    }
    final size = context.size;
    if (_oldSize != size && size != null) {
      _oldSize = size;
      widget.onSizeChange(size);
    }
  }
}

ExpandablePageView是一个有状态的Flutter小部件,用于在ListView中嵌入PageView,并根据页面内容的高度自适应调整列表项的高度。

核心功能包括:

  • 使用SizeReportingWidget获取每个页面内容的实际高度。
  • 使用tweenTweenAnimationBuilder实现平滑的过渡效果,将列表项的高度从前一个页面的高度动画过渡到当前页面的高度。
  • 使用PageView作为子部件,实现页面切换效果。
  • 使用OverflowBox包装页面内容,确保父级组件不对子组件的尺寸施加限制,从而正确计算页面内容的高度。
  • 使用SizeReportingWidget来通知每个页面内容的实际尺寸变化。

总结:ExpandablePageView组件在实现PageView在ListView中嵌入的功能上起到了关键作用。它利用SizeReportingWidget来获取页面内容的高度,并根据内容高度动态调整列表项的高度。这个组件使得在ListView中展示PageView成为可能,并提供了良好的用户体验。

由于篇幅的原因今天我们就写到这里,在下一篇文章中,我们将继续探讨PageView中的view如何与父容器进行通信。这将进一步扩展我们对PageView的应用能力,使我们能够在页面切换、状态更新等方面与父容器进行交互和数据传递。

期待下一篇文章中的分享,让我们进一步探索PageView中view与父容器之间的通信机制

希望对您有所帮助谢谢!