栗子:
在切换屏幕方向的时候改变布局排列方式,并且保证状态不会重置
Center( child: MediaQuery.of(context).orientation == Orientation.portrait ? Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Counter(), Container(child: Counter(key: _globalKey)), ], ) : Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Counter(), Container(child: Counter(key: _globalKey)), ], ), )
上面是最开始写的代码,
通过上面的动图就会发现,第二个 Container 的状态是正确的,第一个则不对,因为第一个没有使用 GlobalKey,所以需要给第一个也加上 GlobalKey,如下:
Center( child: MediaQuery.of(context).orientation == Orientation.portrait ? Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Counter(key: _globalKey1), Counter(key: _globalKey2) ], ) : Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Counter(key: _globalKey1), Container(child: Counter(key: _globalKey2)) ], ), )
但是这样的写法确实有些 low,并且这种需求我们其实不需要 GlobalKey 也可以实现,代码如下:
Center( child: Flex( direction: MediaQuery.of(context).orientation == Orientation.portrait ? Axis.vertical : Axis.horizontal, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[Counter(), Counter()], ), )
使用了 Flex 之后,在 build 的时候 Flex 没有发生改变,所以就会重新找到 Element,所以状态也就不会丢失了。
但是如果内部的 Container 在屏幕切换的过程中会重新嵌套,那还是需要使用 GlobalKey,原因就不需要多说了吧!
GlobalKey 的第二种用法
Flutter 属于声明式编程,如果页面中某个组件的需要更新,则会将更新的值提取到全局,在更新的时候修改全局的值,并进行 setState。这就是最推荐的做法。如果这个状态需要在两个 widget 中共同使用,就把状态向上提升,毫无疑问这也是正确的做法。
但是通过 GlobalKey 我们可以直接在别的地方进行更新,获取状态,widget中数据等操作。前提是我们需要拿到 GlobalKey 对象,其实就类似于 Android 中的 findViewById 拿到对应的控件,但是相比 GlobalKey,GlobalKey 可以获取到 State,Widget,RenderObject 等。
下面我们看一下栗子:
final _globalKey = GlobalKey(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Flex( direction: MediaQuery.of(context).orientation == Orientation.portrait ? Axis.vertical : Axis.horizontal, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Counter(key: _globalKey), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () {}, tooltip: 'Increment', child: Icon(Icons.add), ), );
和之前的例子差不多,现在只剩了一个 Counter 了。现在我们需要做的就是在点击 FloatingActionButton 按钮的时候,使这个 Counter 中的计数自动增加,并且获取到他的一些属性,代码如下:
floatingActionButton: FloatingActionButton( onPressed: () { final state = (_globalKey.currentState as _CounterState); state.setState(() => state._count++); final widget = (_globalKey.currentWidget as Counter); final context = _globalKey.currentContext; final render = (_globalKey.currentContext.findRenderObject() as RenderBox); ///宽高度 print(render.size); ///距离左上角的像素 print(render.localToGlobal(Offset.zero)); }, child: Icon(Icons.add), ), );
I/flutter (29222): Size(88.0, 82.0) I/flutter (29222): Offset(152.4, 378.6)
可以看到上面代码中通过 _globakKey 获取到了 三个属性,分别是 state,widget 和 context。
其中使用了 state 对 _count 进行了自增。
而 widget 则就是 Counter 了。
但是 context 又是什么呢,我们点进去源码看一下:
Element? get _currentElement => _registry[this]; BuildContext? get currentContext => _currentElement;
通过上面两句代码就可以看出来 context 其实就是 Element 对象,通过查看继承关系可知道,Element 是继承自 BuildContext 的。
通过这个 context 的 findRenderObject 方法可以获取到 RenderObject ,这个 RenderObject 就是最终显示到屏幕上的东西,通过 RenderObject 我们可以获取到一一些数据,例如 widget 的宽高度,距离屏幕左上角的位置等等。
RenderObject 有很多种类型,例如 RenderBox 等,不同的 Widget 用到的可能并不相同,这里需要注意一点
实例
这个例子我们写一个小游戏,一个列表中有很多不同颜色的小方块,通过拖动这些方块来进行颜色的重排序。
通过点击按钮来打乱顺序,然后长按方框拖动进行重新排序;
下面我们来写一下代码:
final boxes = [ Box(Colors.red[100], key: UniqueKey()), Box(Colors.red[300], key: UniqueKey()), Box(Colors.red[500], key: UniqueKey()), Box(Colors.red[700], key: UniqueKey()), Box(Colors.red[900], key: UniqueKey()), ]; _shuffle() { setState(() => boxes.shuffle()); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( ///可重排序的列表 child: Container( child: ReorderableListView( onReorder: (int oldIndex, newIndex) { if (newIndex > oldIndex) newIndex--; final box = boxes.removeAt(oldIndex); boxes.insert(newIndex, box); }, children: boxes), width: 60, ), ), floatingActionButton: FloatingActionButton( onPressed: () => _shuffle(), child: Icon(Icons.refresh), ), ); }
ReorderableListView:可重排序的列表,支持拖动排序
onReorder:拖动后的回调,会给出新的 index 和 旧的 index,通过这两个参数就可以对位置就行修改,如上所示
scrollDirection:指定横向或者竖向
还有一个需要注意的是 ReorderableListView 的 Item 必须需要一个 key,否则就会报错。
class Box extends StatelessWidget { final Color color; Box(this.color, {Key key}) : super(key: key); @override Widget build(BuildContext context) { return UnconstrainedBox( child: Container( margin: EdgeInsets.all(5), width: 50, height: 50, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(10)), ), ); } }
上面是列表中 item 的 widget,需要注意的是里面使用到了 UnconstrainedBox,因为在 ReorderableListView 中可能使用到了尺寸限制,导致在 item 中设置的宽高无法生效,所以使用了 UnconstrainedBox。
体验了几次之后就发现了一些问题,
比如拖动的时候只能是一维的,只能上下或者左右,
拖动的时候是整个 item 拖动,并且会有一些阴影效果等,
必须是长按才能拖动
因为 ReorderableListView 没有提供属性去修改上面的这些问题,所以我们可以自己实现一个类似的效果。如下:
class _MyHomePageState extends State<MyHomePage> { final colors = [ Colors.red[100], Colors.red[300], Colors.red[500], Colors.red[700], Colors.red[900], ]; _shuffle() { setState(() => colors.shuffle()); } int _slot; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Listener( onPointerMove: (event) { //获取移动的位置 final x = event.position.dx; //如果大于抬起位置的下一个,则互换 if (x > (_slot + 1) * Box.width) { if (_slot == colors.length - 1) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot + 1]; colors[_slot + 1] = temp; _slot++; }); } else if (x < _slot * Box.width) { if (_slot == 0) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot - 1]; colors[_slot - 1] = temp; _slot--; }); } }, child: Stack( children: List.generate(colors.length, (i) { return Box( colors[i], x: i * Box.width, y: 300, onDrag: (Color color) => _slot = colors.indexOf(color), key: ValueKey(colors[i]), ); }), ), ), floatingActionButton: FloatingActionButton( onPressed: () => _shuffle(), child: Icon(Icons.refresh), ), ); } } class Box extends StatelessWidget { final Color color; final double x, y; static final width = 50.0; static final height = 50.0; static final margin = 2; final Function(Color) onDrag; Box(this.color, {this.x, this.y, this.onDrag, Key key}) : super(key: key); @override Widget build(BuildContext context) { return AnimatedPositioned( child: Draggable( child: box(color), feedback: box(color), onDragStarted: () => onDrag(color), childWhenDragging: box(Colors.transparent), ), duration: Duration(milliseconds: 100), top: y, left: x, ); } box(Color color) { return Container( width: width - margin * 2, height: height - margin * 2, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)), ); } }
可以看到上面我们将 ReorderableListView 直接改成了 Stack , 这是因为在 Stack 中我们可以再 子元素中通过 Positioned 来自由的控制其位置。并且在 Stack 外面套了一层 Listener,这是用来监听移动的事件。
接着我们看 Box,Box 就是可以移动的小方块。在最外层使用了 带动画的 Positioned,在 Positioned 的位置发生变化之后就会产生平移的动画效果。
接着看一下 Draggable组件,Draggable 是一个可拖拽组件,常用的属性如下:
feedback:跟随拖拽的组件
childWhenDragging:拖拽时 chilid 子组件显示的样式
onDargStarted:第一次按下的回调
上面的代码工作流程如下:
1,当手指按住 Box 之后,计算 Box 的 index 。
2,当手指开始移动时通过移动的位置和按下时的位置进行比较。
3,如果大于,则 index 和 index +1 进行互换,小于则 index 和 index-1互换。
4,进行判决处理,如果处于第一个或最后一个时直接 return。
需要注意的是上面并没有使用 UniqueKey,因为 UniqueKey 是惟一的,在重新 build 的时候 因为 key 不相等,之前的状态就会丢失,导致 AnimatedPositioned 的动画无法执行,所以这里使用 ValueKey。这样就能保证不会出现状态丢失的问题。
当然也可以给每一个 Box 创建一个惟一的 UniqueKey 也可以。
由于是 gif 图,所以就会显得比较卡顿。
问题
其实在上面最终完成的例子中,还是有一些问题,例如只能是横向的,如果是竖着的,就需要重新修改代码。
并且 x 的坐标是从 0 开始计算的,如果在前面还有一些内容就会出现问题了。例如如果是竖着的,在最上面有一个 appbar,则就会出现问题。
修改代码如下所示:
class _MyHomePageState extends State<MyHomePage> { ///... int _slot; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Listener( onPointerMove: (event) { //获取移动的位置 final y = event.position.dy; //如果大于抬起位置的下一个,则互换 if (y > (_slot + 1) * Box.height) { if (_slot == colors.length - 1) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot + 1]; colors[_slot + 1] = temp; _slot++; }); } else if (y < _slot * Box.height) { if (_slot == 0) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot - 1]; colors[_slot - 1] = temp; _slot--; }); } }, child: Stack( children: List.generate(colors.length, (i) { return Box( colors[i], x: 300, y: i * Box.height, onDrag: (Color color) => _slot = colors.indexOf(color), key: ValueKey(colors[i]), ); }), ), ), floatingActionButton: FloatingActionButton( onPressed: () => _shuffle(), child: Icon(Icons.refresh), ), ); } }class _MyHomePageState extends State<MyHomePage> { ///... int _slot; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Listener( onPointerMove: (event) { //获取移动的位置 final y = event.position.dy; //如果大于抬起位置的下一个,则互换 if (y > (_slot + 1) * Box.height) { if (_slot == colors.length - 1) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot + 1]; colors[_slot + 1] = temp; _slot++; }); } else if (y < _slot * Box.height) { if (_slot == 0) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot - 1]; colors[_slot - 1] = temp; _slot--; }); } }, child: Stack( children: List.generate(colors.length, (i) { return Box( colors[i], x: 300, y: i * Box.height, onDrag: (Color color) => _slot = colors.indexOf(color), key: ValueKey(colors[i]), ); }), ), ), floatingActionButton: FloatingActionButton( onPressed: () => _shuffle(), child: Icon(Icons.refresh), ), ); } }
在上面代码中将原本横着的组件变成了竖着的,然后在拖动就会发现问题,如向上拖动的时候需要拖动两格才能移动,这就是因为y轴不是从0开始的,在最上面会有一个 appbar,我们没有将他的高度计算进去,所以就出现了这个问题。
这个时候我们就可以使用 GlobalKey 来解决这个问题:
final _globalKey = GlobalKey(); double _offset; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Column( children: [ SizedBox(height: 30), Text("WelCome", style: TextStyle(fontSize: 28, color: Colors.black)), SizedBox(height: 30), Expanded( child: Listener( onPointerMove: (event) { //获取移动的位置 final y = event.position.dy - _offset; //如果大于抬起位置的下一个,则互换 if (y > (_slot + 1) * Box.height) { if (_slot == colors.length - 1) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot + 1]; colors[_slot + 1] = temp; _slot++; }); } else if (y < _slot * Box.height) { if (_slot == 0) return; setState(() { final temp = colors[_slot]; colors[_slot] = colors[_slot - 1]; colors[_slot - 1] = temp; _slot--; }); } }, child: Stack( key: _globalKey, children: List.generate(colors.length, (i) { return Box( colors[i], x: 180, y: i * Box.height, onDrag: (Color color) { _slot = colors.indexOf(color); final renderBox = (_globalKey.currentContext .findRenderObject() as RenderBox); //获取距离顶部的距离 _offset = renderBox.localToGlobal(Offset.zero).dy; }, key: ValueKey(colors[i]), ); }), ), )) ], ), floatingActionButton: FloatingActionButton( onPressed: () => _shuffle(), child: Icon(Icons.refresh), ), ); }
解决的思路非常简单,
通过 GlobalKey 获取到当前 Stack 距离顶部的位置,然后用dy减去这个位置即可。
优化细节
经过上面的操作,基本的功能都实现了,最后我们优化一下细节,如随机颜色,固定第一个颜色,添加游戏成功检测等。
最终代码如下:
class _MyHomePageState extends State<MyHomePage> { MaterialColor _color; List<Color> _colors; initState() { super.initState(); _shuffle(); } _shuffle() { _color = Colors.primaries[Random().nextInt(Colors.primaries.length)]; _colors = List.generate(8, (index) => _color[(index + 1) * 100]); setState(() => _colors.shuffle()); } int _slot; final _globalKey = GlobalKey(); double _offset; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title), actions: [ IconButton( onPressed: () => _shuffle(), icon: Icon(Icons.refresh, color: Colors.white), ) ]), body: Column( children: [ SizedBox(height: 30), Text("WelCome", style: TextStyle(fontSize: 28, color: Colors.black)), SizedBox(height: 30), Container( width: Box.width - Box.margin * 2, height: Box.height - Box.margin * 2, decoration: BoxDecoration( color: _color[900], borderRadius: BorderRadius.circular(10)), child: Icon(Icons.lock, color: Colors.white), ), SizedBox(height: Box.margin * 2.0), Expanded( child: Center( child: Listener( onPointerMove: event, child: SizedBox( width: Box.width, child: Stack( key: _globalKey, children: List.generate(_colors.length, (i) { return Box( _colors[i], y: i * Box.height, onDrag: (Color color) { _slot = _colors.indexOf(color); final renderBox = (_globalKey.currentContext .findRenderObject() as RenderBox); //获取距离顶部的距离 _offset = renderBox.localToGlobal(Offset.zero).dy; }, onEnd: _checkWinCondition, ); }), ), ), ), )) ], ), ); } _checkWinCondition() { List<double> lum = _colors.map((e) => e.computeLuminance()).toList(); bool success = true; for (int i = 0; i < lum.length - 1; i++) { if (lum[i] > lum[i + 1]) { success = false; break; } } print(success ? "成功" : ""); } event(event) { //获取移动的位置 final y = event.position.dy - _offset; //如果大于抬起位置的下一个,则互换 if (y > (_slot + 1) * Box.height) { if (_slot == _colors.length - 1) return; setState(() { final temp = _colors[_slot]; _colors[_slot] = _colors[_slot + 1]; _colors[_slot + 1] = temp; _slot++; }); } else if (y < _slot * Box.height) { if (_slot == 0) return; setState(() { final temp = _colors[_slot]; _colors[_slot] = _colors[_slot - 1]; _colors[_slot - 1] = temp; _slot--; }); } } } class Box extends StatelessWidget { final double x, y; final Color color; static final width = 200.0; static final height = 50.0; static final margin = 2; final Function(Color) onDrag; final Function onEnd; Box(this.color, {this.x, this.y, this.onDrag, this.onEnd}) : super(key: ValueKey(color)); @override Widget build(BuildContext context) { return AnimatedPositioned( child: Draggable( child: box(color), feedback: box(color), onDragStarted: () => onDrag(color), onDragEnd: (drag) => onEnd(), childWhenDragging: box(Colors.transparent), ), duration: Duration(milliseconds: 100), top: y, left: x, ); } box(Color color) { return Container( width: width - margin * 2, height: height - margin * 2, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)), ); } }