flutter如何自定义一个controller

3,587 阅读6分钟

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

背景

最近在写一个flutter-ui库,类似于antd一样的ui库,google了很久,都没有发现一个类似antd这种国人喜欢用的ui库,大部分都是国外的那种material ui,因为公司多个flutter项目都需要用,每次都是写好几遍,而且还很难维护所以才有了这个打算,第一个要写的ui组件就是日历组件,日历的ui以及数据,都已经写完了,目前正好需要给日历写控制器,所以才有了这篇文章

image.png

controller是什么

在无状态组件当中,组件的ui由传入它的参数决定的,组件本身的不需要管理状态。而有状态组件会有多种状态,而它的状态是可以通过外部控制器来控制的。比如TextField,创建一个controller可以给TextField赋值初始值,也可以通过controller来获取到变化之后的value值,而这个控制器就是controller。可以用来控制一个有状态组件的行为以及状态的一个类

为什么要用controller,它解决了什么问题

为什么要用controller呢,起初我也没想明白为什么要用,因为传参数也可以解决类似的问题啊,就拿TextField来说,

  1. 默认值可以通过设置TextField的value值来控制
  2. 获取TextField的最新的值可以通过其onChanged事件来获取最新的

但后来我发现,很多组件内部的行为是没办法通过穿参数来控制的,尤其是在特殊的组件生命周期中,没办法实现,而通过controller,可以很好的解决这个问题,我自己感觉,controller的用处就是提供给外部操作当前组件的能力,包括组件的各种状态,以及组件的各种行为,这里举个栗子🌰

  1. 比如ScrollController,通过创建一个实例,可以通过该controller来控制可滚动组件的滚动行为,比如滚动到某个像素,这个时候就没有办法通过传参数来实现滚动来,当然也可以通传参数来实现,只不过官方没有提供传参数的途径而已,官方提供的是通过controller来控制滚动组件的行为,也可以通过controller去实时拿到当前滚动组件滚动的距离
  2. 再比如TextField的controller,通过它的实例,可以很方便的让父组件获取到当前TextField的信息,而不需要父组件去通过设置onChanged来获取value,不需要写不太优雅的监听事件来监听光标所在的位置

综上,个人理解controller的作用就是暴露组件内部的行为,属性给父元素,使父元素可以很方便使用子元素提供的参数,而不需要去实现监听事件来获取

如何实现一个自定义的controller

回到正题,那么如何实现一个自己的controller呢,对我而言,不会就,抄谁的呢,当然是超官方的!读官方的源码,看它如何实现,然后我们加以模仿,不就是自己的了。窃书不能算偷……窃书!……读书人的事,能算偷么?

这里借鉴了ScrollController的源码,首先分析下源码,以下是ScrollerController的源码,我把看不懂的英文注释删掉了...本菜🐔看不懂就删


import 'dart:async';

import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';

import 'scroll_context.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';

class ScrollController extends ChangeNotifier {
  ScrollController({
    double initialScrollOffset = 0.0,
    this.keepScrollOffset = true,
    this.debugLabel,
  }) : assert(initialScrollOffset != null),
       assert(keepScrollOffset != null),
       _initialScrollOffset = initialScrollOffset;

  double get initialScrollOffset => _initialScrollOffset;
  final double _initialScrollOffset;

  final bool keepScrollOffset;

  final String debugLabel;
  @protected
  Iterable<ScrollPosition> get positions => _positions;
  final List<ScrollPosition> _positions = <ScrollPosition>[];

  bool get hasClients => _positions.isNotEmpty;

  ScrollPosition get position {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
    return _positions.single;
  }


  double get offset => position.pixels;

  Future<void> animateTo(
    double offset, {
    @required Duration duration,
    @required Curve curve,
  }) {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    final List<Future<void>> animations = List<Future<void>>(_positions.length);
    for (int i = 0; i < _positions.length; i += 1)
      animations[i] = _positions[i].animateTo(offset, duration: duration, curve: curve);
    return Future.wait<void>(animations).then<void>((List<void> _) => null);
  }

  void jumpTo(double value) {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    for (final ScrollPosition position in List<ScrollPosition>.from(_positions))
      position.jumpTo(value);
  }

  
  void attach(ScrollPosition position) {
    assert(!_positions.contains(position));
    _positions.add(position);
    position.addListener(notifyListeners);
  }

  
  void detach(ScrollPosition position) {
    assert(_positions.contains(position));
    position.removeListener(notifyListeners);
    _positions.remove(position);
  }

  @override
  void dispose() {
    for (final ScrollPosition position in _positions)
      position.removeListener(notifyListeners);
    super.dispose();
  }

  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition,
  ) {
    return ScrollPositionWithSingleContext(
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

  @override
  String toString() {
    final List<String> description = <String>[];
    debugFillDescription(description);
    return '${describeIdentity(this)}(${description.join(", ")})';
  }

  @mustCallSuper
  void debugFillDescription(List<String> description) {
    if (debugLabel != null)
      description.add(debugLabel);
    if (initialScrollOffset != 0.0)
      description.add('initialScrollOffset: ${initialScrollOffset.toStringAsFixed(1)}, ');
    if (_positions.isEmpty) {
      description.add('no clients');
    } else if (_positions.length == 1) {
      // Don't actually list the client itself, since its toString may refer to us.
      description.add('one client, offset ${offset?.toStringAsFixed(1)}');
    } else {
      description.add('${_positions.length} clients');
    }
  }
}

看了看好像也没多少东西,主意当前类的定义

class ScrollController extends ChangeNotifier

是继承了ChangeNotifier类,看着这个类顿时觉得好眼熟有没有,对了,不就是我们平时写provider用的那个东东嘛,查阅了官方文档,具体是这么解释的

A class that can be extended or mixed in that provides a change notification API using VoidCallback for notifications.

用我这渣渣英语翻译大概的意思就是,一个类,它可以被继承,它可以被混合并且它提供了使用VoidCallback进行通知的 notification Api

盲猜和provider用法差不多,都是观察者模式模式,父组件可以订阅该controller的更改,当该controller通知其他监听器的时候,监听器的回调函数将被执行,上面ScrollController中的attach中正好也使用了notification方法来通知监听者,具体滚动执行的过程没有看到,但是大致了解了controller的工作原理

  1. observer 提供属性以及方法,当需要通知监听者点时候,调用notification去通知
  2. 监听者收到observer 的通知,进行后续的事件处理

好了,知道原理了,开搞

首先得思考,这个controller会提供什么,按照我当前给日历组件的设计,目前会给外部提供当前日历所有的行为事件以及最终的值

  1. 上个月,下个月
  2. Single模式下的value以及Multiple模式下的values值,还有Range模式下的选区的值

这里是我设计的日历组件设计的mode:1. Single模式,只允许有一个处于active的日期。2.Multiple模式,允许多个处于active的日期。 3.Range模式,允许有多个选区(起始日期和结束日期)


class CalendarController extends ChangeNotifier {
  DateTime currentDate = DateTime.now();

  /// 所有激活日期的集合
  List<CalendarCellModel> active = [];
  /// range模式下选中的集合
  List<List<CalendarCellModel>> range = [];

  goPreviousMonth() {
    currentDate = DateUtil.addMonthsToMonthDate(currentDate, -1);
    notifyListeners();
  }

  goNextMonth() {
    currentDate = DateUtil.addMonthsToMonthDate(currentDate, 1);
    notifyListeners();
  }

  @override
  void dispose() {
    range = [];
    active = [];
  }
}

目前我写的controller很简单,只需要给外部父容器提供上一个月,下一个月的方法可以使用就可以,所以我的控制器很简单,只有两个方法,并且方法执行完成之后进行消息通知,通知到各个订阅者,也就是这里的日期组件 在日期组件的 initState方法中,对controller进行监听,从而改变ui

widget.controller.addListener(() {
  setState(() {
    calendarDataSource = CalendarCore.getMonthDetailInfo(
        widget.controller.currentDate.year,
        widget.controller.currentDate.month);
  });
});

最外层父容器是这样的,当前demo用setState临时刷新ui

image.png

看看效果如何

Nov-01-2021 21-16-09.gif

Nov-01-2021 21-22-22.gif

看起来还不错,还有一些ui上的交互需要后续去调整

未完待续...

关于我

微信号:cjs764901388

公众号:xstxoo(小松同学哦)

欢迎大家加我好友或者关注我的微信公众号,不定时的会有一些文章产出,虽然算不上精品,但也是细细推敲,最近入了flutter的坑,就想着做一行爱一行,也不能把自己的头衔写死了就只做前端,只写页面。flutter写起来也蛮舒服的,加油,打工人!