事情缘由
最近有一个新的功能需要在滑动列表同时将顶部导航变为白色然后将标题显示在导航上,再增加一个返回按钮。
实现效果
最开始的实现方式
最初实现方式使用ListView.builder组件,然后监听controller我们可以知道列表滑动的距离然后通过计算得出应该显示导航还是不显示导航。
实现方法
滑动页面时监听ScrollController获取_scrollController.offset也就是滑动的距离,之后赋值_offsetY通过setState渲染整个页面。
注意这里的setState在检测到_offsetY变化给组件一个判断color: _offsetY > _offset ? Colors.white : Colors.transparent这个时候会重新渲染组件超过范围会把导航变为白色,这样可以得到我们滑动导航变色的效果。
踩坑
通过setState重新渲染页面我们得到了想要的效果。但是这个还是有问题的目前发现了两个比较严重的问题。
1、如果使用的是ListView类似的组件(会把所有节点渲染在render树上的组件),列表上会渲染出很多的节点,我们滑动一次列表整个页面就会重新渲染一次造成卡顿。我们可以通过flutter performance工具查看组件重新渲染情况,看到ListView.builder都被重新构建了。如果里面很多子节点并且子节点又很复杂渲染会很吃力造成卡顿。
2、iOS点击状态栏不能返回顶部。导致这个问题出现的原因是自定义的ScrollController;
代码实现
import 'package:flutter/material.dart';
class TopicDetails extends StatefulWidget {
final String memorabilia;
const TopicDetails({this.memorabilia});
@override
_TopicDetailsState createState() => _TopicDetailsState();
}
class _TopicDetailsState extends State<TopicDetails> {
double _offsetY = 0.0;
final double _offset = 98.0;
ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController(initialScrollOffset: 0.0)
..addListener(() {
this.setState(() {
_offsetY = _scrollController.offset;
});
});
}
@override
void dispose() {
super.dispose();
_scrollController.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: <Widget>[
ListView.builder(
controller: _scrollController,
padding: EdgeInsets.only(top: 0.0),
itemBuilder: (BuildContext context, int index) =>
Container(color: Colors.amber),
itemCount: 20,
),
Positioned(
child: Container(
color: _offsetY > _offset ? Colors.white : Colors.transparent,
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Text('Text'),
),
)
],
),
);
}
}
最佳实践
我们问题也找到栏,解决这个问题的方法在于我们要渲染更少的组件不需要重新渲染整个页面。也就是所谓的精确打击。
实现方法
解决iOS点击状态栏不能会顶部问题
为了解决iOS点击状态栏不能返回顶部的问题现在使用NotificationListener<ScrollNotification>组件监听列表的滑动。判断中写了scrollInfo.metrics.pixels < 600意思是如果滑动距离超过了600我们就不需要监听它的变化,这样在长列表滑动中不渲染导航。重构的组件就会很少,滑动也更流畅。
这样做到只渲染AppBar
这里我使用AnimatedBuilder组件进行渲染,也就是实现动画的那个组件。这个组件会将包裹的组件和外部组件隔离,内部渲染时不会触发外部的组件重新渲染,这样就达到了我们想要的效果。
对比之前的重构我们可以发现只有AppBar在重新构建其它组件保持原来的状态。
NotificationListener<ScrollNotification>(
onNotification: _scrollListener,
child: ...
)
bool _scrollListener(ScrollNotification scrollInfo) {
if (scrollInfo.metrics.axis == Axis.vertical &&
scrollInfo.metrics.pixels < 600) {
_colorAnimationController.animateTo(scrollInfo.metrics.pixels / 350);
_textAnimationController
.animateTo((scrollInfo.metrics.pixels - 350) / 50);
return true;
}
return false;
}
代码实现
import 'dart:math';
import 'package:flutter/material.dart';
class StackNavBar extends StatefulWidget {
@override
_StackStackNavBarState createState() => _StackStackNavBarState();
}
class _StackStackNavBarState extends State<StackNavBar>
with TickerProviderStateMixin {
AnimationController _colorAnimationController;
AnimationController _textAnimationController;
Animation _colorTween, _iconColorTween;
Animation<Offset> _transTween;
@override
void initState() {
_colorAnimationController =
AnimationController(vsync: this, duration: Duration(seconds: 0));
_colorTween = ColorTween(begin: Colors.transparent, end: Color(0xFFee4c4f))
.animate(_colorAnimationController);
_iconColorTween = ColorTween(begin: Colors.grey, end: Colors.white)
.animate(_colorAnimationController);
_textAnimationController =
AnimationController(vsync: this, duration: Duration(seconds: 0));
_transTween = Tween(begin: Offset(-10, 40), end: Offset(-10, 0))
.animate(_textAnimationController);
super.initState();
}
bool _scrollListener(ScrollNotification scrollInfo) {
if (scrollInfo.metrics.axis == Axis.vertical) {
_colorAnimationController.animateTo(scrollInfo.metrics.pixels / 350);
_textAnimationController
.animateTo((scrollInfo.metrics.pixels - 350) / 50);
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFEEEEEE),
body: NotificationListener<ScrollNotification>(
onNotification: _scrollListener,
child: Container(
height: double.infinity,
child: Stack(
children: <Widget>[
ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) => Container(
height: 150,
color: Color((Random().nextDouble() * 0xFFFFFF).toInt() << 0)
.withOpacity(1),
width: 250,
),
),
Container(
height: 80,
child: AnimatedBuilder(
animation: _colorAnimationController,
builder: (context, child) => AppBar(
backgroundColor: _colorTween.value,
elevation: 0,
titleSpacing: 0.0,
title: Transform.translate(
offset: _transTween.value,
child: Text(
"Title",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16),
),
),
iconTheme: IconThemeData(
color: _iconColorTween.value,
),
actions: <Widget>[
IconButton(
icon: Icon(
Icons.local_grocery_store,
),
onPressed: () {
// Navigator.of(context).push(TutorialOverlay());
},
),
IconButton(
icon: Icon(
Icons.more_vert,
),
onPressed: () {},
),
],
),
),
),
],
),
),
),
);
}
}
结束语
到这里就已经介绍完了操作方法,核心知识点就是重新渲染组件只渲染部分不要全部渲染会影响性能。