Flutter---CustomScrollView的UI项目(2)
效果图详细描述随着用户往上滑顶部信息栏随着滑动慢慢变小和变透明最后留下了50的大小这50的大小就是用来放标题栏的这回的标题栏没有写在顶部信息区。一共分为四个部分一个是背景渐变一个是可滚动的内容顶部信息栏和列表一个是标题行一个是固定在底部的播放栏。核心代码 flexibleSpace: LayoutBuilder( builder: (context,constraints){ //计算收缩比例 final shrinkOffset constraints.maxHeight; //收缩偏移量 //收缩比例shrinkRatio (currentHeight - minHeight) / (maxHeight - minHeight),这里minHeight collapsedHeight: 50, //收起高度 final shrinkRatio (shrinkOffset - 50) / (270 - 50); // 耳机图 Positioned( //50是收缩时的最终位置109是展开的起始位置109 - 50 59位置变化的距离shrinkRatio收缩比例0.0到1.0 top: 50 (109 - 50) * shrinkRatio.clamp(0.0, 1.0),//耳机图随着用户往上滑而往上滑 left: 0, right: 0, child:Opacity( opacity: shrinkRatio.clamp(0.0, 1.0), //设置耳机图片的透明度 child: Container( //80是耳机图在完全收缩时的高度141是耳机完全展开时的高度(141 - 80)高度的变化范围 height: 80 (141 - 80) * shrinkRatio.clamp(0.0, 1.0), child: Image.asset( assets/images/earphone.png, fit: BoxFit.contain, ), ), ) ,实现步骤1.背景渐变Container( width: double.infinity, height: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white, Colors.purple.withOpacity(0.7), ], ), ), ),2.可滚动内容Positioned.fill( child: CustomScrollView( slivers: [ // 顶部可搜索区域 SliverAppBar( expandedHeight: 270, //展开高度 collapsedHeight: 50, //收起高度 pinned: true, //固定在顶部不让滑动 snap: false, floating: false, automaticallyImplyLeading: false, backgroundColor: Colors.transparent, elevation: 0, toolbarHeight: 0, // 设置工具栏高度为0 flexibleSpace: LayoutBuilder( builder: (context,constraints){ //计算收缩比例 final shrinkOffset constraints.maxHeight; //收缩偏移量 //收缩比例shrinkRatio (currentHeight - minHeight) / (maxHeight - minHeight),这里minHeight collapsedHeight: 50, //收起高度 final shrinkRatio (shrinkOffset - 50) / (270 - 50); return Container( child: Stack( children: [ //背景容器 Positioned.fill( child: Image.asset( assets/images/start.png, fit: BoxFit.cover, ), ), //渐变遮罩 Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withOpacity(0.3), Colors.transparent, ], ) ), ), // 耳机图 Positioned( //50是收缩时的最终位置109是展开的起始位置109 - 50 59位置变化的距离shrinkRatio收缩比例0.0到1.0 top: 50 (109 - 50) * shrinkRatio.clamp(0.0, 1.0),//耳机图随着用户往上滑而往上滑 left: 0, right: 0, child:Opacity( opacity: shrinkRatio.clamp(0.0, 1.0), //设置耳机图片的透明度 child: Container( //80是耳机图在完全收缩时的高度141是耳机完全展开时的高度(141 - 80)高度的变化范围 height: 80 (141 - 80) * shrinkRatio.clamp(0.0, 1.0), child: Image.asset( assets/images/earphone.png, fit: BoxFit.contain, ), ), ) ), ], ), ); }) ), // 内容列表 SliverToBoxAdapter( child: Column( children: [ const SizedBox(height: 8), buildItem( title: 列表1, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表2, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表3, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表4, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表5, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表6, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表7, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表8, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表9, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表9, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表10, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表11, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表12, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), // 添加底部间距为固定播放栏留出空间 const SizedBox(height: 80), // 播放栏高度 边距 ], ), ), ], ), ),3.标题栏Positioned( top:0, left: 0, right: 0, child: Container( height: 100, color: Colors.transparent, child: Column( children: [ // 状态栏间距 SizedBox(height: MediaQuery.of(context).padding.top), Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 20), child:Row( children: [ //SizedBox(width: 20), GestureDetector( onTap: () Navigator.pop(context), child: Image.asset(assets/images/back_white.png,width: 20,height: 20,), ), Spacer(), Text( 标题栏, style: TextStyle(color: Colors.white, fontSize: 24), ), Spacer(), //SizedBox(width: 20), ], ), ), ), ], ), ) ),4.底部播放栏Positioned( left: 10, right: 15, bottom: 15, // 距离底部的距离 child: Container( height: 60, padding: const EdgeInsets.symmetric(horizontal: 20), decoration: BoxDecoration( color: const Color(0xFF2C2C2E), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Image.asset(assets/images/cherry.png,), const SizedBox(width: 10), const Text( Rain, style: TextStyle(color: Colors.white), ), const Spacer(), Image.asset(assets/images/mango.png,), const SizedBox(width: 10), Image.asset(assets/images/banana.png), ], ), ), ),关键点1.为什么标题栏和底部播放栏能够叠放在最上层因为Stack是按顺序绘制的比如Stack( children: [ WidgetA, // 最先绘制 → 最底层 WidgetB, // 其次绘制 → 在WidgetA上面 WidgetC, // 最后绘制 → 最上层 ], )可视化的层级关系┌─────────────────────────────────┐ │ 4. 标题栏 (最上层) │ ← 最后绘制覆盖所有内容 │ ← 固定显示不会被滚动内容遮挡 │ ├─────────────────────────────────┤ │ 3. 底部播放栏 (最上层) │ ← 最后绘制覆盖所有内容 │ ← 固定显示不会被滚动内容遮挡 │ ├─────────────────────────────────┤ │ 2. 可滚动内容 (中间层) │ ← 在背景之上但在固定组件之下 │ ← 可以滚动但不会覆盖固定组件 │ ├─────────────────────────────────┤ │ 1. 背景渐变 (最底层) │ ← 最先绘制作为背景 │ ← 被所有其他层覆盖 │ └─────────────────────────────────┘2.标题栏的缩放是怎么实现的原理①通过 constraints.maxHeight 获取当前高度constraints.maxHeight会随着用户的上滑而减小使用 shrinkRatio 控制内部组件的缩放②SliverAppBar 自动处理高度变化展开时height 270收缩时height 50中间状态height 在 50-270 之间平滑变化③start背景图片能够自动缩放因为使用 Positioned.fillBoxFit.cover,自动填充可用空间④耳机图的top控制位置变化opacity控制透明度变化height控制耳机图的高度变化过程完全展开状态shrinkRatio 1.0SliverAppBar 高度: 270px ┌─────────────────┐ │ 背景图充满270px高度 │ │ 耳机图在109px位置 │ │ 耳机图高度141px │ │ 完全不透明 │ └─────────────────┘半收缩状态shrinkRatio 0.5SliverAppBar 高度: 160px (50 110) ┌─────────────────┐ │ 背景图自动缩放 │ ← BoxFit.cover 自动处理 │ 耳机图在79.5px位置│ ← 50 (109-50)*0.5 │ 耳机图高度110.5px │ ← 80 (141-80)*0.5 │ 半透明 │ └─────────────────┘完全收缩状态shrinkRatio 0.0SliverAppBar 高度: 50px ┌─────────────────┐ │ 背景图缩放至50px │ │ 耳机图在50px位置 │ │ 耳机图高度80px │ │ 完全透明 │ └─────────────────┘完整的缩放时间线用户向上滚动时 1. SliverAppBar 高度从 270px 开始减小 2. LayoutBuilder 的 constraints.maxHeight 实时更新 3. shrinkRatio 从 1.0 逐渐减小到 0.0 4. 所有组件根据 shrinkRatio 平滑过渡 5. 达到 collapsedHeight: 50px 时停止缩放代码实例import package:flutter/cupertino.dart; import package:flutter/material.dart; class SleepPage extends StatefulWidget { const SleepPage({super.key}); override StateStatefulWidget createState() _SleepPage(); } class _SleepPage extends StateSleepPage { override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.transparent, body: Stack( children: [ //1.背景渐变 Container( width: double.infinity, height: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white, Colors.purple.withOpacity(0.7), ], ), ), ), // 2. 可滚动内容 Positioned.fill( child: CustomScrollView( slivers: [ // 顶部可搜索区域 SliverAppBar( expandedHeight: 270, //展开高度 collapsedHeight: 50, //收起高度 pinned: true, //固定在顶部不让滑动 snap: false, floating: false, automaticallyImplyLeading: false, backgroundColor: Colors.transparent, elevation: 0, toolbarHeight: 0, // 设置工具栏高度为0 flexibleSpace: LayoutBuilder( builder: (context,constraints){ //计算收缩比例 final shrinkOffset constraints.maxHeight; //收缩偏移量 //收缩比例shrinkRatio (currentHeight - minHeight) / (maxHeight - minHeight),这里minHeight collapsedHeight: 50, //收起高度 final shrinkRatio (shrinkOffset - 50) / (270 - 50); return Container( child: Stack( children: [ //背景容器 Positioned.fill( child: Image.asset( assets/images/start.png, fit: BoxFit.cover, ), ), //渐变遮罩 Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withOpacity(0.3), Colors.transparent, ], ) ), ), // 耳机图 Positioned( //50是收缩时的最终位置109是展开的起始位置109 - 50 59位置变化的距离shrinkRatio收缩比例0.0到1.0 top: 50 (109 - 50) * shrinkRatio.clamp(0.0, 1.0),//耳机图随着用户往上滑而往上滑 left: 0, right: 0, child:Opacity( opacity: shrinkRatio.clamp(0.0, 1.0), //设置耳机图片的透明度 child: Container( //80是耳机图在完全收缩时的高度141是耳机完全展开时的高度(141 - 80)高度的变化范围 height: 80 (141 - 80) * shrinkRatio.clamp(0.0, 1.0), child: Image.asset( assets/images/earphone.png, fit: BoxFit.contain, ), ), ) ), ], ), ); }) ), // 内容列表 SliverToBoxAdapter( child: Column( children: [ const SizedBox(height: 8), buildItem( title: 列表1, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表2, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表3, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表4, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表5, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表6, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表7, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表8, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表9, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表9, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表10, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表11, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), buildItem( title: 列表12, icon: assets/images/apple.png, rightIcon: assets/images/apple.png, ), const SizedBox(height: 8), // 添加底部间距为固定播放栏留出空间 const SizedBox(height: 80), // 播放栏高度 边距 ], ), ), ], ), ), // 3.标题行 Positioned( top:0, left: 0, right: 0, child: Container( height: 100, color: Colors.transparent, child: Column( children: [ // 状态栏间距 SizedBox(height: MediaQuery.of(context).padding.top), Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 20), child:Row( children: [ //SizedBox(width: 20), GestureDetector( onTap: () Navigator.pop(context), child: Image.asset(assets/images/back_white.png,width: 20,height: 20,), ), Spacer(), Text( 标题栏, style: TextStyle(color: Colors.white, fontSize: 24), ), Spacer(), //SizedBox(width: 20), ], ), ), ), ], ), ) ), // 4. 固定在底部的播放栏 Positioned( left: 10, right: 15, bottom: 15, // 距离底部的距离 child: Container( height: 60, padding: const EdgeInsets.symmetric(horizontal: 20), decoration: BoxDecoration( color: const Color(0xFF2C2C2E), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Image.asset(assets/images/cherry.png,), const SizedBox(width: 10), const Text( Rain, style: TextStyle(color: Colors.white), ), const Spacer(), Image.asset(assets/images/mango.png,), const SizedBox(width: 10), Image.asset(assets/images/banana.png), ], ), ), ), ], ), ); } //功能小卡片 Widget buildItem({ required String title, required String icon, required String rightIcon, VoidCallback? callback }) { return Container( padding: EdgeInsets.symmetric(horizontal: 20), margin: EdgeInsets.only(left: 15,right: 20), height: 52, width: double.infinity, decoration:BoxDecoration( color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: Material( color: Colors.transparent, child: Ink( child: InkWell( onTap: callback, child: Row( children: [ Image.asset(icon), SizedBox(width: 15,), Text(title,style: const TextStyle(color: Colors.white,fontSize: 18),), const Expanded(child: SizedBox()), Image.asset(rightIcon), ], ) ), ), ) ); } }