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.fill,BoxFit.cover,自动填充可用空间)
④耳机图的top控制位置变化,opacity控制透明度变化,height控制耳机图的高度变化

过程:

完全展开状态:shrinkRatio = 1.0

SliverAppBar 高度: 270px ┌─────────────────┐ │ 背景图充满270px高度 │ │ 耳机图在109px位置 │ │ 耳机图高度141px │ │ 完全不透明 │ └─────────────────┘

半收缩状态:shrinkRatio = 0.5

SliverAppBar 高度: 160px (50 + 110) ┌─────────────────┐ │ 背景图自动缩放 │ ← BoxFit.cover 自动处理 │ 耳机图在79.5px位置│ ← 50 + (109-50)*0.5 │ 耳机图高度110.5px │ ← 80 + (141-80)*0.5 │ 半透明 │ └─────────────────┘

完全收缩状态:shrinkRatio = 0.0

SliverAppBar 高度: 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 State<StatefulWidget> createState() => _SleepPage(); } class _SleepPage extends State<SleepPage> { @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), ], ) ), ), ) ); } }