Flutter (十五) CustomScrollView PageView

CustomScrollView

less 复制代码
import 'package:flutter/material.dart';

void main(List<String> args) {
  runApp(const MaterialApp(home: HomePage()));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('CustomScrollView 全方位知识点')),
      body: CustomScrollView(
        slivers: [
          SliverToBoxAdapter(
            child: Center(
              child: Container(
                width: 120,
                height: 120,
                color: Colors.amber,
                alignment: Alignment.center,
                child: PageView.builder(
                  itemBuilder: (BuildContext context, int index) {
                    return Container(
                      color: Colors.red,
                      alignment: Alignment.center,
                      child: Text("第${index + 1} 个"),
                    );
                  },
                  itemCount: 10,
                ),
              ),
            ),
          ),
          SliverPersistentHeader(delegate: _GridDelegate()),
        ],
      ),
    );
  }
}

class _GridDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(
      color: Colors.black,
      height: 100,
      child: ListView.builder(
        itemCount: 30,
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) => Container(
          margin: index == 29
              ? EdgeInsets.fromLTRB(0, 0, 0, 0)
              : EdgeInsets.fromLTRB(0, 0, 10, 0),
          color: Colors.green,
          height: 80,
          width: 100,
          alignment: Alignment.center,
          child: Text(
            "分类 $index",
            style: TextStyle(color: Colors.white, fontSize: 25),
          ),
        ),
      ),
    );
  }

  @override
  // TODO: implement maxExtent
  double get maxExtent => 200;

  @override
  // TODO: implement minExtent
  double get minExtent => 60;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    // TODO: implement shouldRebuild
    return false;
  }
}

PageView的使用

less 复制代码
import 'package:flutter/material.dart';

void main(List<String> args) {
  runApp(const MaterialApp(home: HomePage()));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('CustomScrollView 全方位知识点')),
      body: CustomScrollView(
        slivers: [
          SliverToBoxAdapter(
            child: Center(
              child: Container(
                width: 120,
                height: 120,
                color: Colors.amber,
                alignment: Alignment.center,
                child: PageView.builder(
                  itemBuilder: (BuildContext context, int index) {
                    return Container(
                      color: Colors.red,
                      alignment: Alignment.center,
                      child: Text("第${index + 1} 个"),
                    );
                  },
                  itemCount: 10,
                ),
              ),
            ),
          ),
          SliverPersistentHeader(delegate: _GridDelegate()),
        ],
      ),
    );
  }
}

class _GridDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(
      color: Colors.black,
      height: 100,
      child: ListView.builder(
        itemCount: 30,
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) => Container(
          margin: index == 29
              ? EdgeInsets.fromLTRB(0, 0, 0, 0)
              : EdgeInsets.fromLTRB(0, 0, 10, 0),
          color: Colors.green,
          height: 80,
          width: 100,
          alignment: Alignment.center,
          child: Text(
            "分类 $index",
            style: TextStyle(color: Colors.white, fontSize: 25),
          ),
        ),
      ),
    );
  }

  @override
  // TODO: implement maxExtent
  double get maxExtent => 200;

  @override
  // TODO: implement minExtent
  double get minExtent => 60;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    // TODO: implement shouldRebuild
    return false;
  }
}

PaggeView进阶

less 复制代码
import 'dart:async';
import 'package:flutter/material.dart';

void main(List<String> args) {
  runApp(const MaterialApp(home: HomePage()));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('PageView 知识点')),
      body: SingleChildScrollView(
        child: Column(
          children: [
            const SizedBox(height: 10),
            _Section1_Basic(),
            _Section2_Indicator(),
            _Section3_AutoLoop(),
            _Section4_Advanced(),
          ],
        ),
      ),
    );
  }
}

// ────────────────────────────────────────────────────────────────
// ① 基础 PageView
// ────────────────────────────────────────────────────────────────
class _Section1_Basic extends StatelessWidget {
  const _Section1_Basic();

  @override
  Widget build(BuildContext context) {
    return _Card(
      title: '① 基础 PageView',
      subtitle: '三种构造函数',
      children: const [
        Text(
          'PageView({ children })          一次性构建\n'
          'PageView.builder               懒加载 ✅\n'
          'PageView.custom                自定义 Sliver\n\n'
          '// 默认水平滚动,每页占满屏幕\n'
          '// 99% 场景用 PageView.builder',
          style: TextStyle(fontSize: 11, fontFamily: 'monospace'),
        ),
      ],
    );
  }
}

// ────────────────────────────────────────────────────────────────
// ② 指示器点点
// ────────────────────────────────────────────────────────────────
class _Section2_Indicator extends StatefulWidget {
  const _Section2_Indicator();

  @override
  State<_Section2_Indicator> createState() => _Section2_IndicatorState();
}

class _Section2_IndicatorState extends State<_Section2_Indicator> {
  final _controller = PageController();
  int _current = 0;
  final _count = 5;

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final next = _controller.page?.round() ?? 0;
      if (next != _current && mounted) {
        setState(() => _current = next);
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return _Card(
      title: '② 指示器点点',
      subtitle: 'Stack 叠加,底部圆点根据当前页高亮',
      children: [
        SizedBox(
          height: 140,
          child: Stack(
            children: [
              // 底层:PageView
              PageView.builder(
                controller: _controller,
                itemCount: _count,
                itemBuilder: (_, i) => Container(
                  margin: const EdgeInsets.symmetric(horizontal: 4),
                  color: i.isEven ? Colors.amber : Colors.blue,
                  alignment: Alignment.center,
                  child: Text('第 ${i + 1} 页',
                      style: const TextStyle(fontSize: 18, color: Colors.white)),
                ),
              ),
              // 上层:底部圆点指示器
              Positioned(
                left: 0,
                right: 0,
                bottom: 8,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: List.generate(_count, (i) {
                    final isActive = i == _current;
                    return AnimatedContainer(
                      duration: const Duration(milliseconds: 200),
                      margin: const EdgeInsets.symmetric(horizontal: 3),
                      width: isActive ? 18 : 6,
                      height: 6,
                      decoration: BoxDecoration(
                        color: isActive ? Colors.white : Colors.white54,
                        borderRadius: BorderRadius.circular(3),
                      ),
                    );
                  }),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 6),
        const Text('滑动上方 PageView → 底部圆点会跟着变',
            style: TextStyle(fontSize: 11, color: Colors.blue)),
        const SizedBox(height: 6),
        Container(
          padding: const EdgeInsets.all(8),
          color: Colors.amber.shade50,
          child: const Text(
            '// 指示器核心代码:\n\n'
            '// 1. PageController 监听\n'
            'controller.addListener(() {\n'
            '  final page = controller.page?.round() ?? 0;\n'
            '  setState(() => current = page);\n'
            '});\n\n'
            '// 2. Stack 叠加\n'
            'Stack(\n'
            '  children: [\n'
            '    PageView(...),\n'
            '    Positioned(\n'
            '      bottom: 8,\n'
            '      child: Row(       // 圆点行\n'
            '        children: List.generate(count, (i) {\n'
            '          final active = i == current;\n'
            '          return AnimatedContainer(\n'
            '            width: active ? 18 : 6,  // 选中的变长\n'
            '            height: 6,\n'
            '            decoration: BoxDecoration(\n'
            '              color: active ? Colors.white : Colors.white54,\n'
            '              borderRadius: BorderRadius.circular(3),\n'
            '            ),\n'
            '          );\n'
            '        }),\n'
            '      ),\n'
            '    ),\n'
            '  ],\n'
            ')',
            style: TextStyle(fontSize: 11, fontFamily: 'monospace'),
          ),
        ),
      ],
    );
  }
}

// ────────────────────────────────────────────────────────────────
// ③ 无限循环 + 自动轮播
// ────────────────────────────────────────────────────────────────
class _Section3_AutoLoop extends StatefulWidget {
  const _Section3_AutoLoop();

  @override
  State<_Section3_AutoLoop> createState() => _Section3_AutoLoopState();
}

class _Section3_AutoLoopState extends State<_Section3_AutoLoop> {
  // 真实页数
  final _realCount = 5;
  // 虚拟页数:用一个很大的数模拟无限
  // 初始 page 设在中间,往前/往后都能翻很久
  final _virtualCount = 10000;

  late final PageController _controller;
  int _current = 0;
  Timer? _timer;
  bool _isUserDragging = false;

  @override
  void initState() {
    super.initState();
    // 初始跳到中间附近,这样前后都有很多页可翻
    _controller = PageController(initialPage: _virtualCount ~/ 2);
    _current = _virtualCount ~/ 2;

    _controller.addListener(_onPageChanged);
    _startAutoPlay();
  }

  void _onPageChanged() {
    final next = _controller.page?.round() ?? _current;
    if (next != _current && mounted) {
      setState(() => _current = next);
    }
  }

  void _startAutoPlay() {
    _timer?.cancel();
    _timer = Timer.periodic(const Duration(seconds: 2), (_) {
      if (!_isUserDragging && _controller.hasClients) {
        _controller.nextPage(
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeInOut,
        );
      }
    });
  }

  void _pauseAutoPlay() {
    _isUserDragging = true;
    _timer?.cancel();
  }

  void _resumeAutoPlay() {
    _isUserDragging = false;
    _startAutoPlay();
  }

  @override
  void dispose() {
    _timer?.cancel();
    _controller
      ..removeListener(_onPageChanged)
      ..dispose();
    super.dispose();
  }

  /// 虚拟 index → 真实 index
  int get _realIndex => _current % _realCount;

  @override
  Widget build(BuildContext context) {
    return _Card(
      title: '③ 无限循环 + 自动轮播',
      subtitle: '虚拟大页数模拟无限 + Timer.periodic 自动翻页',
      children: [
        SizedBox(
          height: 160,
          child: Stack(
            children: [
              // 底层:PageView(虚拟 10000 页)
              NotificationListener<ScrollStartNotification>(
                onNotification: (_) {
                  _pauseAutoPlay();
                  return false;
                },
                child: NotificationListener<ScrollEndNotification>(
                  onNotification: (_) {
                    _resumeAutoPlay();
                    return false;
                  },
                  child: PageView.builder(
                    controller: _controller,
                    itemCount: _virtualCount,
                    itemBuilder: (_, index) {
                      final realIndex = index % _realCount;
                      final colors = [
                        Colors.red,
                        Colors.amber,
                        Colors.blue,
                        Colors.green,
                        Colors.purple,
                      ];
                      return Container(
                        margin: const EdgeInsets.symmetric(horizontal: 4),
                        color: colors[realIndex],
                        alignment: Alignment.center,
                        child: Text('真实第 ${realIndex + 1} 页',
                            style: const TextStyle(
                                fontSize: 18, color: Colors.white)),
                      );
                    },
                  ),
                ),
              ),
              // 上层:圆点指示器(只显示真实页数)
              Positioned(
                left: 0,
                right: 0,
                bottom: 10,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: List.generate(_realCount, (i) {
                    final isActive = i == _realIndex;
                    return AnimatedContainer(
                      duration: const Duration(milliseconds: 200),
                      margin: const EdgeInsets.symmetric(horizontal: 3),
                      width: isActive ? 18 : 6,
                      height: 6,
                      decoration: BoxDecoration(
                        color: isActive ? Colors.white : Colors.white54,
                        borderRadius: BorderRadius.circular(3),
                      ),
                    );
                  }),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 6),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            const Text('自动轮播中(2 秒/页)',
                style: TextStyle(fontSize: 11, color: Colors.green)),
            Text('当前:$_current (真实 $_realIndex)',
                style: const TextStyle(fontSize: 10, fontFamily: 'monospace')),
          ],
        ),
        const SizedBox(height: 6),
        Container(
          padding: const EdgeInsets.all(8),
          color: Colors.amber.shade50,
          child: const Text(
            '// 无限循环核心思路:\n'
            '// 不用真的循环,而是用虚拟大页数\n'
            '// 初始跳到中间 → 前后都能翻很多页\n'
            '\n'
            'final realCount = 5;\n'
            'final virtualCount = 10000;  // 虚拟 10000 页\n'
            '\n'
            'PageController(initialPage: virtualCount ~/ 2);\n'
            '\n'
            '// itemBuilder 里取模映射到真实数据:\n'
            'itemBuilder: (_, index) {\n'
            '  final realIndex = index % realCount;\n'
            '  return MyItem(data[realIndex]);\n'
            '},\n'
            '\n'
            '// 自动轮播:\n'
            'Timer.periodic(Duration(seconds: 2), (_) {\n'
            '  controller.nextPage(duration: 400ms, curve: easeInOut);\n'
            '});\n\n'
            '// 用户手动拖拽时暂停,松手后恢复\n'
            'NotificationListener<ScrollStartNotification>\n'
            'NotificationListener<ScrollEndNotification>',
            style: TextStyle(fontSize: 10.5, fontFamily: 'monospace'),
          ),
        ),
      ],
    );
  }
}

// ────────────────────────────────────────────────────────────────
// ④ 进阶属性总结
// ────────────────────────────────────────────────────────────────
class _Section4_Advanced extends StatelessWidget {
  const _Section4_Advanced();

  @override
  Widget build(BuildContext context) {
    return _Card(
      title: '④ PageView 进阶属性',
      subtitle: 'viewportFraction / padEnds / pageSnapping',
      children: const [
        Text(
          '// 视口比例(一页显示一部分,露出前后页)\n'
          'PageView(\n'
          '  viewportFraction: 0.8,  // 每页占 80% 宽度\n'
          '  ...\n'
          ')\n\n'
          '// 两端是否自动填充空白(默认 true)\n'
          'padEnds: true   // 首页前末页后有空白\n'
          'padEnds: false  // 首页贴左末页贴右\n\n'
          '// 是否按页吸附(默认 true)\n'
          'pageSnapping: true   // 停在整页位置\n'
          'pageSnapping: false  // 停在任意位置(像普通 ListView)\n\n'
          '// 滚动方向\n'
          'scrollDirection: Axis.horizontal  // 默认\n'
          'scrollDirection: Axis.vertical    // 垂直翻页',
          style: TextStyle(fontSize: 11, fontFamily: 'monospace'),
        ),
        SizedBox(height: 6),
        Text('⚠️ dispose 必释放:',
            style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
        Text(
          '@override\n'
          'void dispose() {\n'
          '  timer?.cancel();     // Timer 必须 cancel\n'
          '  controller.dispose(); // PageController 必须 dispose\n'
          '  super.dispose();\n'
          '}',
          style: TextStyle(fontSize: 11, fontFamily: 'monospace'),
        ),
      ],
    );
  }
}

// ────────────────────────────────────────────────────────────────
class _Card extends StatelessWidget {
  final String title;
  final String subtitle;
  final List<Widget> children;
  const _Card({required this.title, this.subtitle = '', required this.children});

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: Colors.grey.shade300),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.05),
            blurRadius: 6,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
          if (subtitle.isNotEmpty) ...[
            const SizedBox(height: 2),
            Text(subtitle,
                style: TextStyle(fontSize: 11, color: Colors.grey.shade600, fontStyle: FontStyle.italic)),
          ],
          const SizedBox(height: 10),
          ...children,
        ],
      ),
    );
  }
}
相关推荐
WILLF1 小时前
Python vs JavaScript 异常处理对比
前端·python
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY31
前端
岁月留痕1681 小时前
4 Dart 篇:异步(并发)
前端
MartinYeung51 小时前
[论文学习]AdInject:通过广告投放对Web代理发起真实世界黑盒攻击
前端·学习
用户2181697049301 小时前
Flutter (十六) 组件通信
前端
mONESY1 小时前
React 前端如何不傻等后端接口?
前端·javascript·后端
岁月留痕1681 小时前
6 Flutter 篇:Flutter 分层式架构设计
前端
乘风gg1 小时前
AI Coding:从单兵提效到多 Agent 的团队全链路协作模式
前端·ai编程·claude
万维易源1 小时前
免费药品信息查询:用API 读懂常用药
java·前端·数据库·药品信息·药品查询·药品查询api