当前位置: 首页 > news >正文

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), ], ) ), ), ) ); } }
http://www.jsqmd.com/news/1186254/

相关文章:

  • Cesium OGC- tms服务教程
  • 2026 东莞黄金上门回收 全城当天上门 - 奢侈品回收评测
  • ZeroTermux:Android设备上的终极Linux终端环境解决方案
  • 2026年7月最新济南雷达官方售后联系电话与客户服务中心网点地址 - 亨得利钟表维修中心
  • hot100 岛屿数量(200)
  • RedisFullCheck 企业级部署:大规模 Redis 环境数据一致性监控方案
  • 设计工具大集合:gh_mirrors/we/web-dev-resources助你打造专业UI界面 [特殊字符]
  • 如何快速上手PaintingLight:10分钟安装与基础使用教程
  • 亿企代账口碑怎么样?看亿企赢合作机构怎么说 - 热点速览
  • 滑动窗口算法解析:无重复字符最长子串的优化实现
  • Unity URP屏幕空间描边实战:7大技巧实现高性能卡通渲染
  • 专业级Godot RPG游戏开发:从零构建回合制战斗系统的完整指南
  • 2026最新版Skyline Console功能盘点:支持多语言与自定义主题的现代仪表盘
  • CuckooSQL架构解密:Apache Arrow+LLVM如何打造极速SQL引擎
  • tech.ml.dataset实战:5个真实场景展示如何高效处理百万级数据
  • 2026吉州黄金变现指南:认准这几家“大盘价”回收店,无折旧无套路,当场到账! - 铂衡汇黄金珠宝
  • Clawdbot本地化AI Agent架构解析与实践指南
  • AI 模型成本战:企业账单从月费百万到精打细算的生存指南
  • 扩散模型PyTorch实战:从DDPM原理到图像生成完整指南
  • 劳力士官方直营售后维修网点|官方网站权威公布(2026年7月最新) - 劳力士官方维修中心
  • 深入解析llama-nv-embed-reasoning-3b架构:双向注意力机制如何提升推理能力
  • 2026 上海围挡租赁围挡销售商家测评,市政工地围挡租售机构深度实测 - LYL仔仔
  • Xel常见问题解答:解决原生Web应用开发中的30个典型问题
  • 2026年河北边坡防护网厂家挑选攻略 旭涛边坡等优质企业盘点 - 资讯报道
  • 预测型AI偏见审计:从数据到决策的全流程公平性保障
  • YOLOv8道路坑洼检测实战:从数据标注到工程部署完整指南
  • XQuery与BaseX完美结合:10个实用查询示例带你精通XML数据处理
  • tech.ml.dataset vs Pandas:为什么Clojure的数据处理库更值得选择?
  • 英国政务AI实战:算力即服务如何重构税务与移民服务
  • 湖北沙盘模型与写实沙盘模型制作:2026 年专业选择指南与卓越服务商解析 - 米諾