FlutterComponent最佳实践之Widget尺寸

1,632 阅读5分钟

在Flutter和在Native中,对一个Widget的尺寸测量,一直都是一个非常麻烦的事情,大部分时间,我们都是按照约束和具体的尺寸来进行布局,但有些时候,我们不得不拿到动态的Widget尺寸来实现自己的一些布局策略。通常来说,我们会有三方面的需求。

  • 测量自己的尺寸
  • 测量Parent的尺寸
  • 测量Child的尺寸

测量自己的尺寸

要获取你自身的Widget尺寸,其实只需要通过RenderBox即可获取。

Size s = (context.findRenderObject() as RenderBox).size;
// 空安全写法
Size s = (context.findRenderObject() as RenderBox?)?.size ?? Size.zero;

不过要注意的是,findRenderObject不能写在build方法中,因为这个时候,renderobject还未挂载。

测量Parent尺寸

对于Parent来说,我们可以通过LayoutBuilder来快速获得它的约束范围,从而获取Parent的尺寸,代码如下。

return LayoutBuilder(
  builder: (context , constraints ) {
    print('-----$constraints');
  },
);

�这是一个很方便的功能,因为你可以根据当前宽度和比例来调整当前Widget的尺寸,从而更加符合约束的视觉限制。

测量Child尺寸

测量Child的尺寸要比上面两种要复杂一点,我们一般还是会通过findRenderObject来获取尺寸信息,然后将其通过回调传递给当前Widget。

class MeasurableWidget extends StatefulWidget {
  const MeasurableWidget({
    Key? key,
    required this.child,
    required this.onSized,
  }) : super(key: key);
  final Widget child;
  final void Function(Size size) onSized;

  @override
  _MeasurableWidgetState createState() => _MeasurableWidgetState();
}

class _MeasurableWidgetState extends State<MeasurableWidget> {
  bool _hasMeasured = false;

  @override
  Widget build(BuildContext context) {
    Size size = (context.findRenderObject() as RenderBox?)?.size ?? Size.zero;
    if (size != Size.zero) {
      widget.onSized.call(size);
    } else if (!_hasMeasured) {
      // Need to build twice in order to get size
      scheduleMicrotask(() => setState(() => _hasMeasured = true));
    }
    return widget.child;
  }
}

�我们创建一个MeasurableWidget,用来测量Child的尺寸,并传入回调来获取尺寸,使用代码如下。

MeasurableWidget(
  onSized: (Size size) {
    print('====$size');
  },
  child: const Text(
    'xxxx',
  ),
)

�这个方法其实遇到了和「测量自身」一样的问题,那就是build的时候,RenderObject未挂载,所以这里需要Render两次才能获取最终的尺寸,这样其实并不是很优雅,虽然大部分时候,局部Context的刷新并不太耗性能,但是还是应该尽可能的减少刷新的次数。

那么获取Child的尺寸有什么用呢?通过获取Child的尺寸,我们可以根据尺寸来做一些偏移,例如下面的示例。

Size _widgetSize = Size.zero;
Widget build(BuildContext context){
   Offset o = Offset(_widgetSize.size.width/2, _widgetSize.size.height/2);
   return Transform.translate(
      offset: o, 
      child: MeasurableWidget(child: ..., onSized: _handleWidgetSized);
   );
}
 
void _handleWidgetSized(Size value) => setState(()=>_widgetSize = value);

优化

那么我们是否有办法来避免这个「两次刷新」呢?答案是肯定的,我们不能一次性获取尺寸的原因,实际上就是RenderObject没挂载好,所以,我们可以自定义一个RenderObject,给它设置回调来获取尺寸。

首先,我们先定义一个RenderProxyBox�,并不需要修改什么逻辑,只要在其performLayout�方法中,通过WidgetsBinding.instance.addPostFrameCallback�来增加一个回调监听即可。

class MeasureSizeRenderObject extends RenderProxyBox {
  MeasureSizeRenderObject(this.onChange);

  void Function(Size size) onChange;

  Size _prevSize = Size.zero;

  @override
  void performLayout() {
    super.performLayout();
    Size newSize = child?.size ?? Size.zero;
    if (_prevSize == newSize) return;
    _prevSize = newSize;
    WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize));
  }
}

�接下来,再定义一个SingleChildRenderObjectWidget�来承载它即可。

class MeasurableWidget extends SingleChildRenderObjectWidget {
  const MeasurableWidget({
    Key? key,
    required this.onChange,
    required Widget child,
  }) : super(key: key, child: child);

  final void Function(Size size) onChange;

  @override
  RenderObject createRenderObject(BuildContext context) => MeasureSizeRenderObject(onChange);
}

�使用也很简单,使用MeasurableWidget�包裹下就好了。

MeasurableWidget(
  onChange: (Size size) {
    print('====$size');
  },
  child: const Text(
    'xxxx',
  ),
)

�是不是有点意思,其核心原理还是通过WidgetsBinding.instance.addPostFrameCallback来获取尺寸回调的时机,但是封装了一层,就优雅了很多。

再优化

前面我们是通过自定义RenderProxyBox来处理addPostFrameCallback调用的时机问题,那么除了这种方式以为,还可以通过mixin来处理这个问题,代码如下所示。

mixin MeasurableMixin<T extends StatefulWidget> on State<T> {
  @override
  BuildContext get context;

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback(_afterRendering);
    super.initState();
  }

  void _afterRendering(Duration timeStamp) {
    RenderObject? renderObject = context.findRenderObject();
    if (renderObject != null) {
      Size size = renderObject.paintBounds.size;
      var box = renderObject as RenderBox;
      onSized(
        Rect.fromLTWH(
          box.localToGlobal(Offset.zero).dx,
          box.localToGlobal(Offset.zero).dy,
          size.width,
          size.height,
        ),
      );
    } else {
      onSized(Rect.zero);
    }
  }

  void onSized(Rect rect);
}

typedef OnSized = void Function(Rect rect);

�那么有了这个mixin之后,就可以很方便的封装一个Widget,来创建类似前面的回调。

class MeasurableWidget extends StatefulWidget {
  final Widget child;

  final OnSized onSized;

  const MeasurableWidget({
    Key? key,
    required this.child,
    required this.onSized,
  }) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _MeasurableWidgetState();
  }
}

class _MeasurableWidgetState extends State<MeasurableWidget> with MeasurableMixin<MeasurableWidget> {
  @override
  Widget build(BuildContext context) => widget.child;

  @override
  void onSized(Rect rect) => widget.onSized(rect);
}

�这样我们就可以很方便的使用它了。

MeasurableWidget(
  onSized: (Rect rect) {
    print('------$rect');
  },
  child: const Text(
    'xxxx',
  ),
)

�可以发现,其实我们解决问题的方法有很多,但殊途同归,有很多时候,我们都可以从不同角度去解决同一个问题,这样对我们不仅仅是技术的提高,也是认知的提高。

通过Key

前面我们在获取尺寸的时候,要么是在Build之后通过context获取,要么就是创建Custom RenderObject来增加监听,这些方法的本质,实际上都是通过WidgetsBinding.instance.addPostFrameCallback获取刷新时机,再通过findRenderObject来获取尺寸,所以,借助Key,我们可以在不自定义Custom RenderObject的前提下,获取尺寸的一般方法。

要注意的是,未渲染的Widget,通过GlobalKey获取的currentContext为null。

final GlobalKey globalKey = GlobalKey();
var showSize = 'show me Text!';

void getSizeWithContext() {
  final containerWidth = globalKey.currentContext?.size?.width;
  final containerHeight = globalKey.currentContext?.size?.height;
  print('Context Container Width $containerWidth\n'
        'Context Container Height $containerHeight');
}

void getSizeWithRenderBox() {
  RenderBox? box = globalKey.currentContext?.findRenderObject() as RenderBox?;
  final containerWidth = box?.size.width;
  final containerHeight = box?.size.height;
  print('Context Container Width $containerWidth\n'
        'Context Container Height $containerHeight');
}

void getSizeWithPaintBounds() {
  RenderObject? box = globalKey.currentContext?.findRenderObject();
  print('PaintBounds Container Width ${box?.paintBounds.width}\n'
        'PaintBounds Container Height ${box?.paintBounds.height}');
}

@override
void initState() {
  WidgetsBinding.instance.addPostFrameCallback(getPositionWithPostFrameCallback);
  super.initState();
}

getPositionWithPostFrameCallback(_) => getSizeWithRenderBox();

�前两种方式,无非是通过GlobalKey�来获取BuildContext�和RenderBox�,其本质是一样的。但是这些方法都只限制于获取Box模型中的尺寸,如果在Sliver结构中国,则只能通过其内部的容器Widget来间接获取其尺寸。

Size Notifications

在Flutter中,Notifications是向上冒泡的,如果你需要某些尺寸,并在多个层级的Widget上传递,Notifications就是一个最好的选择,你需要做的只是定义一些自定义Notifications。

首先,我们创建一个Notifications。

class WidgetMeasuredNotification extends Notification {
  WidgetMeasuredNotification(this.size);

  final Size size;
}

�然后在获取到尺寸的地方通过dispatch将size分发出来,在需要监听的地方,使用NotificationListener�来做监听即可。

NotificationListener<WidgetMeasuredNotification>(
  onNotification: (notification) {
    print('=====${notification.size}');
    return true;
  },
  child: MeasurableWidget(
    onSized: (Size size) {
      WidgetMeasuredNotification(size).dispatch(context);
    },
    child: const Text(
      'xxxxx',
    ),
  ),
)

�这样就可以将Size在Widget Tree上传递了。这样的好处就是Widget和监听者之间没有太多的耦合,即使跨越多个层级,你依然可以获取这些通知,它的使用场景很多,例如在一些菜单动画中,你需要在MenuController和被选中的MenuButtons之间获取这种尺寸的处理。

MediaQuery.of(context)

MediaQuery.of(context)是我们经常访问的一个代码,用来获取到设备相关的一些尺寸信息,但是它的调用稍微复杂一点,比如。

MediaQuery.of(context).size.height

类似的还有很多,所以我们可以借助Dart的extension来对BuildContext进行拓展。

extension SizedContext on BuildContext {
  /// Returns same as MediaQuery.of(context)
  MediaQueryData get mq => MediaQuery.of(this);

  /// Returns if Orientation is landscape
  bool get isLandscape => mq.orientation == Orientation.landscape;

  /// Returns same as MediaQuery.of(context).size
  Size get sizePx => mq.size;

  /// Returns same as MediaQuery.of(context).size.width
  double get widthPx => sizePx.width;

  /// Returns same as MediaQuery.of(context).height
  double get heightPx => sizePx.height;

  /// Returns diagonal screen pixels
  double get diagonalPx {
    final Size s = sizePx;
    return sqrt((s.width * s.width) + (s.height * s.height));
  }

  /// Returns fraction (0-1) of screen width in pixels
  double widthPct(double fraction) => fraction * widthPx;

  /// Returns fraction (0-1) of screen height in pixels
  double heightPct(double fraction) => fraction * heightPx;
}

�这样在使用的时候,可以直接通过context来引用。

context.sizePx
context.mq.padding

等等。

要注意的是,MediaQuery.of(context).size.height�在release mode下第一次获取的值可能是0,所以需要对这种情况进行下处理,避免出现0/0的问题。

欢迎大家关注我的公众号——【群英传】,专注于「Android」「Flutter」「Kotlin」
我的语雀知识库——www.yuque.com/xuyisheng