本文仅做分析。项目作者见 github

GitHub 源地址: direct-select-flutter

先上预览图:

DirectSelect 预览图
DirectSelect

三个文件:

  • direct_select_list.dart
  • direct_select_item.dart
  • direct_select_container.dart

先让我们看一下用法

点击查看代码

    //...
	return Scaffold(
      key: scaffoldKey,
      body: DirectSelectContainer(
        child: Column(
          children: [
            SizedBox(height: 200.0),
            MealSelector(data: _meals, label: "To which meal?"),
          ],
        ),
      ),
    );
	//...
	
class MealSelector extends StatelessWidget {
  final buttonPadding = const EdgeInsets.fromLTRB(0, 8, 0, 0);

  final List data;
  final String label;

  MealSelector({@required this.data, @required this.label});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
            alignment: AlignmentDirectional.centerStart,
            margin: EdgeInsets.only(left: 4),
            child: Text(label)),
        Padding(
          padding: buttonPadding,
          child: Container(
            decoration: BoxDecoration(
              boxShadow: [
                new BoxShadow(
                  color: Colors.black.withOpacity(0.06),
                  spreadRadius: 4,
                  offset: new Offset(0.0, 0.0),
                  blurRadius: 15.0,
                ),
              ],
            ),
            child: Card(
              child: Row(
                mainAxisSize: MainAxisSize.max,
                children: [
                  Expanded(
                    child: Padding(
                      child: DirectSelectList(
                        values: data,
                        defaultItemIndex: 0,
                        itemBuilder: (String value) {
                          return DirectSelectItem(
                            itemHeight: 56,
                            value: value,
                            itemBuilder: (context, value) {
                              return Text(value);
                            });
                        },
                        focusedItemDecoration: BoxDecoration(
                          border: BorderDirectional(
                            bottom: BorderSide(width: 1, color: Colors.black12),
                            top: BorderSide(width: 1, color: Colors.black12),
                          ),
                        ),
                        onItemSelectedListener:
                            (value, selectedIndex, context) {},
                        onUserTappedListener: () {},
                      ),
                      padding: EdgeInsets.only(left: 12),
                    ),
                  ),
                  Padding(
                    padding: EdgeInsets.only(right: 8),
                    child: Icon(
                      Icons.unfold_more,
                      color: Colors.blueAccent,
                    ),
                  )
                ],
              ),
            ),
          ),
        ),
      ],
    );
  }
}

MealSelector 是对 DirectSelectList<T> 的一个外观上的包装。它大概长这样:

MealSelector

在一开始,我们先忽略 DirectSelectContainer, 该 widget 用于包裹当前已选择菜单和菜单列表,同时对外提供方法以控制菜单列表,稍后解释。

DirectSelectList<T> values 接受 List<T> 类型,用于构建菜单内容。defaultItemIndex 表示默认选中的菜单索引,itemBuilder 用于构建单个“菜单项目”,如下图:

DirectSelectItem

focusedItemDecoration,用于着重标识当前选中的item,如下图:

focusedItemDecoration

onItemSelectedListeneronUserTappedListener 都是回调,onItemSelectedListener是指选item后的回调,onUserTappedListener 是仅仅用户点击了这个控件而产生的回调。

我们可以看到在 itemBuilder 下,我们传入了 :

DirectSelectItem<String>(
		itemHeight: 56,
		value: value,
		itemBuilder: (context, value) {
		  return Text(value);
		})

下面来分析一下 DirectSelectItem<T>。


direct_select_item.dart # DirectSelectItem<T>

点击查看代码
  
class DirectSelectItem extends StatefulWidget {
  //Value of item
  final T value;

  //定义此项目是否被选中
  final bool isSelected;

  //列表中项目的高度
  final double itemHeight;

  //初始项目大小
  final scale = ValueNotifier(1.0);

  //未选中的项目不透明度
  final opacity = ValueNotifier(0.5);

  //值越大,最大比例减少
  final scaleFactor;

  final Widget Function(BuildContext context, T value) itemBuilder;

  DirectSelectItem({
    Key key,
    this.scaleFactor = 4.0,
    @required this.value,
    @required this.itemBuilder,
    this.itemHeight = 48.0,
    this.isSelected = false,
  }) : super(key: key);

  @override
  State createState() {
    return DirectSelectItemState(isSelected: isSelected);
  }

  void updateScale(double scale) {
    this.scale.value = scale;
  }

  void updateOpacity(double opacity) {
    this.opacity.value = opacity;
  }

  Widget getSelectedItem(GlobalKey animatedStateKey,
      GlobalKey paddingGlobalKey) {
    return RectGetter(
      key: paddingGlobalKey,
      child: DirectSelectItem(
        value: value,
        key: animatedStateKey,
        itemHeight: itemHeight,
        itemBuilder: itemBuilder,
        isSelected: true,
      ),
    );
  }
}

class DirectSelectItemState extends State>
    with SingleTickerProviderStateMixin {
  final bool isSelected;

  AnimationController animationController;
  Animation _animation;
  Tween _tween;

  DirectSelectItemState({this.isSelected = false});

  bool isScaled = false;

  Future runScaleTransition({@required bool reverse}) {
    if (reverse) {
      return animationController.reverse();
    } else {
      return animationController.forward(from: 0.0);
    }
  }

  @override
  void initState() {
    super.initState();

    animationController =
        AnimationController(duration: Duration(milliseconds: 150), vsync: this);
    _tween = Tween(begin: 1.0, end: 1 + 1 / widget.scaleFactor);
    _animation = _tween.animate(animationController)
      ..addListener(() {
        setState(() {});
      });
  }

  @override
  Widget build(BuildContext context) {
    if (isSelected) {
      return Row(
        mainAxisSize: MainAxisSize.max,
        children: [
          Expanded(
            child: Container(
              color: Colors.transparent,
              height: widget.itemHeight,
              alignment: AlignmentDirectional.centerStart,
              child: Transform.scale(
                  scale: _animation.value,
                  alignment: Alignment.topLeft,
                  child: widget.itemBuilder(context, widget.value)),
            ),
          ),
        ],
      );
    } else {
      return Material(
        color: Colors.transparent,
        child: Container(
          child: ValueListenableBuilder(
            valueListenable: widget.scale,
            builder: (context, value, child) {
              return Opacity(
                opacity: widget.opacity.value,
                child: Container(
                    height: widget.itemHeight,
                    child: Transform.scale(
                        scale: value,
                        alignment: Alignment.topLeft,
                        child: widget.itemBuilder(context, widget.value)),
                    alignment: AlignmentDirectional.centerStart),
              );
            },
          ),
        ),
      );
    }
  }

  @override
  void dispose() {
    super.dispose();
    animationController.dispose();
  }
}

具体看一下 DirectSelectItem<T> 后,可以发现这是菜单单个item widget,

在我们滑动菜单的时候,改变了单个item的大小和不透明度。所有在 DirectSelectItem<T> 中, scale opacity 都使用了 ValueNotifier<double>

那么什么是 ValueNotifier<T> 呢?它是一个可以监听widget 变量,当变量值发生变化时,可以自动更新 widget。下面是一个示例:

点击查看代码

class BtnWrap extends StatefulWidget {
  final text = ValueNotifier('default');

  void setText(String text) {
    this.text.value = text;
  }

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

class _BtnWrapState extends State {
  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: widget.text,
      builder: (context, value, child) {
        return MaterialButton(
          onPressed: () {
            widget.text.value = 'NEW';
          },
          child: Text(value),
        );
      },
    );
  }
}

解释完 ValueNotifier<T> 后,我们回过头看看:在 DirectSelectItemState<T> 中,我们通过 runScaleTransition 来控制item的缩放动画 (Transform.scale)。

DirectSelectItemState<T> build 方法中通过 isSelected 来构建已选中的item 和 未选中的item,如图:

已选中
未选中

direct_select_list.dart # DirectSelectList<T>

DirectSelectItem<T> 大致介绍完成,下面我们看看 DirectSelectList<T>

点击查看代码

class PaddingItemController {
  GlobalKey paddingGlobalKey = RectGetter.createGlobalKey();
}

typedef DirectSelectItemsBuilder = DirectSelectItem Function(T value);

//typedef ItemSelected = Future Function(
//    DirectSelectList owner, double location);

/// Widget that contains items and responds to user's interaction
/// Usage Example
///
///     final dsl2 = DirectSelectList(
///        values: _numbers,
///        itemBuilder: (String value) => getDropDownMenuItem(value),
///        focusedItemDecoration: _getDslDecoration());
class DirectSelectList extends StatefulWidget {
  //Item widgets
  final List> items;

  //Current focused item overlay
  final Decoration focusedItemDecoration;

  //Default selected item index
  //默认所选项目索引
  final int defaultItemIndex;

  //Notifies state about new item selected
  //通知状态有关选定的新项目
  final ValueNotifier selectedItem;

  //Function to execute when item selected
  //  //选择项目时执行的功能
  final void Function(T value, int selectedIndex, BuildContext context)
      onItemSelectedListener;

  //Callback for action when user just tapped instead of hold and scroll
  //用户仅点击而不是按住并滚动时的回调操作
  final VoidCallback onUserTappedListener;

  final PaddingItemController paddingItemController = PaddingItemController();

  DirectSelectList({
    Key key,
    @required List values,
    @required DirectSelectItemsBuilder itemBuilder,
    this.onItemSelectedListener,
    this.focusedItemDecoration,
    this.defaultItemIndex = 0,
    this.onUserTappedListener,
  })  : items = values.map((val) => itemBuilder(val)).toList(),
        selectedItem = ValueNotifier(defaultItemIndex),
        assert(defaultItemIndex + 1 <= values.length + 1),
        super(key: key);

  @override
  State createState() {
    return _DirectSelectState();
  }

  //todo pass item height in this class and build items with that height
  //todo 在此类中传递项目高度并使用该高度构建项目
  double itemHeight() {
    if (items != null && items.isNotEmpty) {
      return items.first.itemHeight;
    }
    return 0.0;
  }

  int getSelectedItemIndex() {
    if (selectedItem != null) {
      return selectedItem.value;
    } else {
      return 0;
    }
  }

  void setSelectedItemIndex(int index) {
    if (selectedItem != null && index != selectedItem.value) {
      selectedItem.value = index;
    }
  }

  T getSelectedItem() {
    return items[selectedItem.value].value;
  }
}

class _DirectSelectState extends State> {
  final GlobalKey animatedStateKey =
      GlobalKey();

  Future Function(DirectSelectList, double) onTapEventListener;

  void Function(double) onDragEventListener;

  /// 标识list是否可见
  bool isOverlayVisible = false;
  int lastSelectedItem;

  bool _isShowUpAnimationRunning = false;

  Map selectedItemWidgets = Map();

  @override
  void initState() {
    super.initState();
    lastSelectedItem = widget.defaultItemIndex;
    _updateSelectItemWidget();
  }

  @override
  void didUpdateWidget(DirectSelectList oldWidget) {
    widget.paddingItemController.paddingGlobalKey =
        oldWidget.paddingItemController.paddingGlobalKey;
    _updateSelectItemWidget();
    super.didUpdateWidget(widget);
  }

  void _updateSelectItemWidget() {
    selectedItemWidgets.clear();
    for (int index = 0; index < widget.items.length; index++) {
      selectedItemWidgets.putIfAbsent(
        index,
        () => widget.items[index].getSelectedItem(
          animatedStateKey,
          widget.paddingItemController.paddingGlobalKey,
        ),
      );
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final dsListener = DirectSelectContainer.of(context);
    assert(dsListener != null,
        "A DirectSelectList must inherit a DirectSelectContainer!");

    this.onTapEventListener = dsListener.toggleListOverlayVisibility;
    this.onDragEventListener = dsListener.performListDrag;
  }

  @override
  Widget build(BuildContext context) {
    widget.selectedItem.addListener(() {
      if (widget.onItemSelectedListener != null) {
        widget.onItemSelectedListener(
            widget.items[widget.selectedItem.value].value,
            widget.selectedItem.value,
            this.context);
      }
    });

    bool transitionEnded = false;

    // 使用 ValueListenableBuilder 数值同步
    return ValueListenableBuilder(
        valueListenable: widget.selectedItem,
        builder: (context, value, child) {
          final selectedItem = selectedItemWidgets[value];
          return GestureDetector(
            child: selectedItem,
            // 点击
            onTap: () {
              // 回调点击事件
              if (widget.onUserTappedListener != null) {
                widget.onUserTappedListener();
              }
            },
            // 按下
            onTapDown: (tapDownDetails) async {
              if (!isOverlayVisible) {
                transitionEnded = false;
                _isShowUpAnimationRunning = true;

                await animatedStateKey.currentState
                    .runScaleTransition(reverse: false);

                if (!transitionEnded) {
                  await _showListOverlay(_getItemTopPosition(context));
                  _isShowUpAnimationRunning = false;
                  lastSelectedItem = value;
                }
              }
            },
            // 按下后抬起
            onTapUp: (tapUpDetails) async {
              await _hideListOverlay(_getItemTopPosition(context));
              animatedStateKey.currentState.runScaleTransition(reverse: true);
            },
            // 通过主按钮与屏幕接触并垂直移动的指针已沿垂直方向移动。
            onVerticalDragUpdate: (dragInfo) {
              if (!_isShowUpAnimationRunning) {
                _showListOverlay(dragInfo.primaryDelta);
              }
            },
            onVerticalDragEnd: (dragDetails) async {
              transitionEnded = true;
              _dragEnd();
            },
            onHorizontalDragEnd: (horizontalDetails) async {
              transitionEnded = true;
              _dragEnd();
            },
          );
        });
  }

  void _dragEnd() async {
    await _hideListOverlay(_getItemTopPosition(context));
    animatedStateKey.currentState.runScaleTransition(reverse: true);
  }

  double _getItemTopPosition(BuildContext context) {
    final RenderBox itemBox = context.findRenderObject();
    final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
    return itemRect.top;
  }

  _hideListOverlay(double dy) async {
    if (isOverlayVisible) {
      isOverlayVisible = false;
      //fix to prevent stuck scale if selected item is the same as previous
      await onTapEventListener(widget, dy);
      if (lastSelectedItem == widget.selectedItem.value) {
        animatedStateKey.currentState.runScaleTransition(reverse: true);
      }
    }
  }

  _showListOverlay(double dy) {
    if (!isOverlayVisible) {
      isOverlayVisible = true;
      onTapEventListener(widget, _getItemTopPosition(context));
    } else {
      onDragEventListener(dy);
    }
  }
}

可以看到 selectedItem 也用 ValueNotifier<int> 来定义,selectedItem 存储当前选择的 item index 。

onItemSelectedListener, onUserTappedListener 均为用户操作回调。事件回调具体用 GestureDetector 来实现:

GestureDetector 触发点击事件顺序为 onTapDown -> onTapUp -> onTap

            // 按下
            onTapDown: (tapDownDetails) async {
              if (!isOverlayVisible) {
                transitionEnded = false;
                _isShowUpAnimationRunning = true;

                await animatedStateKey.currentState
                    .runScaleTransition(reverse: false);

                if (!transitionEnded) {
                  await _showListOverlay(_getItemTopPosition(context));
                  _isShowUpAnimationRunning = false;
                  lastSelectedItem = value;
                }
              }
            },

onTapDown 把 flag 设置完成,且通过 animatedStateKey 调用 DirectSelectItem<T>runScaleTransition 方法放大菜单,该方法上面介绍过,改变菜单的大小。同时调用 _showListOverlay 展开菜单列表。

            onTapUp: (tapUpDetails) async {
              await _hideListOverlay(_getItemTopPosition(context));
              animatedStateKey.currentState.runScaleTransition(reverse: true);
            },

onTapUp 当用户抬起手指,调用 _hideListOverlay 隐藏列表,同时调用 runScaleTransition 缩小菜单。

PS: _hideListOverlay_showListOverlay 最终会调用 onTapEventListener onDragEventListener 。这两个监听器的实现,后面会详解。

            onTap: () {
              // 回调点击事件
              if (widget.onUserTappedListener != null) {
                widget.onUserTappedListener();
              }
            },

onTap 调用用户传递的点击事件。

onTapDown -> onTapUp -> onTap 最终的动画效果:

长按动画

到这,长按动画就分析完了。至于 滑动 (onVerticalDragUpdateonVerticalDragEndonHorizontalDragEnd),会放在后面进行分析。


direct_select_container.dart # DirectSelectContainer & DirectSelectContainerState

点击查看代码

class DirectSelectContainer extends StatefulWidget {
  //Actually content of screen
  //实际画面内容
  final Widget child;

  //How fast list is scrolled
  //列表滚动速度
  final int dragSpeedMultiplier;

  final Decoration decoration;

  const DirectSelectContainer({
    Key key,
    this.child,
    this.dragSpeedMultiplier = 2,
    this.decoration,
  }) : super(key: key);

  @override
  State createState() {
    return DirectSelectContainerState();
  }

  static DirectSelectGestureEventListeners of(BuildContext context) {
    if (context.dependOnInheritedWidgetOfExactType<
            _InheritedContainerListeners>() ==
        null) {
      throw Exception(
          "A DirectSelectList must inherit a DirectSelectContainer!");
    }
    return context
        .dependOnInheritedWidgetOfExactType<_InheritedContainerListeners>()
        .listeners;
  }
}

class DirectSelectContainerState extends State
    with SingleTickerProviderStateMixin
    implements DirectSelectGestureEventListeners {
  bool isOverlayVisible = false;

  ScrollController _scrollController;
  DirectSelectList _currentList =
      DirectSelectList(itemBuilder: (val) => null, values: []);
  double _currentScrollLocation = 0;

  double _adjustedTopOffset = 0.0;

  AnimationController fadeAnimationController;

  int lastSelectedItem = 0;

  double listPadding = 0.0;

  final scrollToListElementAnimationDuration = Duration(milliseconds: 200);
  final fadeAnimationDuration = Duration(milliseconds: 200);

  @override
  void initState() {
    super.initState();
    fadeAnimationController = AnimationController(
      duration: fadeAnimationDuration,
      vsync: this,
    );
  }

  @override
  Widget build(BuildContext context) {
    double topOffset = 0.0;
    RenderObject object = context.findRenderObject();
    if (object?.parentData is ContainerBoxParentData) {
      topOffset = (object.parentData as ContainerBoxParentData).offset.dy;
    }

    listPadding = MediaQuery.of(context).size.height;

    _adjustedTopOffset = _currentScrollLocation - topOffset;
    _scrollController = ScrollController(
        initialScrollOffset: listPadding -
            _currentScrollLocation +
            topOffset +
            _currentList.getSelectedItemIndex() * _currentList.itemHeight());

    return Stack(
      children: [
        _InheritedContainerListeners(
          listeners: this,
          child: widget.child,
        ),
        Visibility(
          visible: isOverlayVisible,
          child: FadeTransition(
            opacity: fadeAnimationController
                .drive(CurveTween(curve: Curves.easeOut)),
            child: Column(
              children: [
                Expanded(
                  child: Stack(
                    children: [
                      _getListWidget(),
                      _getSelectionOverlayWidget(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        )
      ],
    );
  }

  Widget _getListWidget() {
    var paddingLeft = 0.0;

    if (_currentList.items.isNotEmpty) {
      Rect rect = RectGetter.getRectFromKey(
          _currentList.paddingItemController.paddingGlobalKey);
      if (rect != null) {
        paddingLeft = rect.left;
      }
    }

    Decoration dslContainerDecoration;
    if (widget.decoration == null) {
      final theme = Theme.of(context);
      dslContainerDecoration =
          BoxDecoration(color: theme.scaffoldBackgroundColor);
    } else {
      dslContainerDecoration = widget.decoration;
    }

    return Container(
        decoration: dslContainerDecoration,
        child: ListView.builder(
          padding: EdgeInsets.only(left: paddingLeft),
          controller: _scrollController,
          itemCount: _currentList.items.length + 2,
          itemBuilder: (BuildContext context, int index) {
            if (index == 0 || index == _currentList.items.length + 1) {
              return Container(height: listPadding);
            }
            final item = _currentList.items[index - 1];
            final normalScale = 1.0;
            if (lastSelectedItem == index - 1) {
              item.updateScale(_calculateNewScale(normalScale));
            } else {
              item.updateScale(normalScale);
            }
            return item;
          },
        ));
  }

  Widget _getSelectionOverlayWidget() {
    return Positioned(
        top: _adjustedTopOffset,
        left: 0,
        right: 0,
        height: _currentList.itemHeight(),
        child: Container(
            height: _currentList.itemHeight(),
            decoration: _currentList.focusedItemDecoration != null
                ? _currentList.focusedItemDecoration
                : BoxDecoration()));
  }

  void performListDrag(double dragDy) {
    try {
      if (_scrollController != null && _scrollController.position != null) {
        final currentScrollOffset = _scrollController.offset;
        double allowedOffset = _allowedDragDistance(
            currentScrollOffset + _adjustedTopOffset,
            dragDy * widget.dragSpeedMultiplier);
        if (allowedOffset != 0.0) {
          _scrollController.jumpTo(currentScrollOffset + allowedOffset);

          final scrollPixels =
              _scrollController.offset - listPadding + _adjustedTopOffset;
          final selectedItemIndex = _getCurrentListElementIndex(scrollPixels);
          lastSelectedItem = selectedItemIndex;

          _performScaleTransformation(scrollPixels, selectedItemIndex);
        }
      }
    } catch (e) {
      print(e);
    }
  }

  double _allowedDragDistance(double currentScrollOffset, double position) {
    double newPosition = currentScrollOffset + position;
    double endOfListPosition =
        (_currentList.items.length - 1) * _currentList.itemHeight() +
            listPadding;
    if (newPosition < listPadding) {
      return listPadding - currentScrollOffset;
    } else if (newPosition > endOfListPosition) {
      return endOfListPosition - currentScrollOffset;
    } else {
      return position;
    }
  }

  void _performScaleTransformation(double scrollPixels, int selectedItemIndex) {
    final neighbourDistance = _getNeighbourListElementDistance(scrollPixels);
    int neighbourIncrementDirection =
        neighbourScrollDirection(neighbourDistance);

    int neighbourIndex = lastSelectedItem + neighbourIncrementDirection;

    double neighbourDistanceToCurrentItem =
        _getNeighbourListElementDistanceToCurrentItem(neighbourDistance);

    if (neighbourIndex < 0 || neighbourIndex > _currentList.items.length - 1) {
      //incorrect neighbour index quit
      return;
    }
    _currentList.items[selectedItemIndex].updateOpacity(1.0);
    _currentList.items[neighbourIndex].updateOpacity(0.5);

    _currentList.items[selectedItemIndex]
        .updateScale(_calculateNewScale(neighbourDistanceToCurrentItem));
    _currentList.items[neighbourIndex]
        .updateScale(_calculateNewScale(neighbourDistance.abs()));
  }

  double _calculateNewScale(double distance) =>
      1.0 + distance / _currentList.items[lastSelectedItem].scaleFactor;

  int neighbourScrollDirection(double neighbourDistance) {
    int neighbourScrollDirection = 0;
    if (neighbourDistance > 0) {
      neighbourScrollDirection = 1;
    } else {
      neighbourScrollDirection = -1;
    }
    return neighbourScrollDirection;
  }

  double _getNeighbourListElementDistanceToCurrentItem(
      double neighbourDistance) {
    double neighbourDistanceToCurrentItem = (1 - neighbourDistance.abs());

    if (neighbourDistanceToCurrentItem > 1 ||
        neighbourDistanceToCurrentItem < 0) {
      neighbourDistanceToCurrentItem = 1.0;
    }
    return neighbourDistanceToCurrentItem;
  }

  int _getCurrentListElementIndex(double scrollPixels) {
    int selectedElement = (scrollPixels / _currentList.itemHeight()).round();
    final maxElementIndex = _currentList.items.length;

    if (selectedElement < 0) {
      selectedElement = 0;
    }
    if (selectedElement >= maxElementIndex) {
      selectedElement = maxElementIndex - 1;
    }
    return selectedElement;
  }

  double _getNeighbourListElementDistance(double scrollPixels) {
    double selectedElementDeviation =
        (scrollPixels / _currentList.itemHeight());
    int selectedElement = _getCurrentListElementIndex(scrollPixels);
    return selectedElementDeviation - selectedElement;
  }

  Future toggleListOverlayVisibility(
      final DirectSelectList visibleList, final double location) async {
    if (isOverlayVisible) {
      try {
        await _scrollController.animateTo(
          listPadding -
              _adjustedTopOffset +
              lastSelectedItem * _currentList.itemHeight(),
          duration: scrollToListElementAnimationDuration,
          curve: Curves.ease,
        );
      } catch (e) {} finally {
        _currentList.setSelectedItemIndex(lastSelectedItem);
        sleep(const Duration(milliseconds: 200));
        await fadeAnimationController.reverse();
        setState(() {
          _hideListOverlay();
        });
      }
    } else {
      setState(() {
        _showListOverlay(visibleList, location);
      });
    }
  }

  _showListOverlay(DirectSelectList visibleList, double location) async {
    _currentList = visibleList;
    _currentScrollLocation = location;
    lastSelectedItem = _currentList.getSelectedItemIndex();
    _currentList.items[lastSelectedItem].updateOpacity(1.0);
    isOverlayVisible = true;
    await fadeAnimationController.forward(from: 0.0);
  }

  void _hideListOverlay() {
    _scrollController.dispose();
    _scrollController = null;
    _currentList.items[lastSelectedItem].updateScale(1.0);
    _currentScrollLocation = 0;
    _adjustedTopOffset = 0;
    isOverlayVisible = false;
  }
}

上面动画看起来似乎是菜单放大,然后出现待选菜单列表。但当我们区看 DirectSelectContainer & DirectSelectContainerState 源码的时候会发现DirectSelectContainerState它用了 Stack 包裹了已选择菜单(DirectSelectItem<T>)和菜单列表(DirectSelectContainerState#_getListWidget()),下面依旧图解:

图解 DirectSelectContainer

上图对应的代码位置为 DirectSelectContainer#build

//.......
 return Stack(
      children: <Widget>[
        _InheritedContainerListeners(
          listeners: this,
          child: widget.child,
        ),
        Visibility(
          visible: isOverlayVisible,
          child: FadeTransition(
            opacity: fadeAnimationController
                .drive(CurveTween(curve: Curves.easeOut)),
            child: Column(
              children: <Widget>[
                Expanded(
                  child: Stack(
                    children: <Widget>[
                      _getListWidget(),
                      _getSelectionOverlayWidget(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        )
      ],
    );
//.......

        _InheritedContainerListeners(
          listeners: this,
          child: widget.child,
        ),

DirectSelectContainer#buildStack 中,_InheritedContainerListeners 它继承了 InheritedWidgetInheritedWidget 是一个用于数据共享的 widget, 具体可以百度搜索。

direct_select_container.dart # DirectSelectGestureEventListeners & _InheritedContainerListeners

要理清 DirectSelectContainerState就必须要先看两个类(DirectSelectGestureEventListeners_InheritedContainerListeners):

/// 接口以供 [_DirectSelectState] 调用。
class DirectSelectGestureEventListeners {
  /// 控制菜单列表的显示
  /// [list] 菜单列表
  /// [location] 显示位置
  toggleListOverlayVisibility(DirectSelectList list, double location) =>
      throw 'Not implemented.';

  /// 控制菜单滑动
  /// [dragDy] 滑动值
  performListDrag(double dragDy) => throw 'Not implemented';
}

/// Allows Direct Select List implementations to
/// 允许直接选择列表实现
class _InheritedContainerListeners extends InheritedWidget {

  /// See [DirectSelectGestureEventListeners]
  final DirectSelectGestureEventListeners listeners;

  _InheritedContainerListeners({
    Key key,
    @required this.listeners,
    @required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(_InheritedContainerListeners old) =>
      old.listeners != listeners;
}

注释已经写的很明确了。_InheritedContainerListeners 内部存储着 DirectSelectGestureEventListeners 监听器,而 DirectSelectContainerState 又实现了这个接口。

DirectSelectContainer 对外提供一个方法,用于获取 DirectSelectGestureEventListeners

  static DirectSelectGestureEventListeners of(BuildContext context) {
    if (context.dependOnInheritedWidgetOfExactType<
            _InheritedContainerListeners>() ==
        null) {
      throw Exception(
          "A DirectSelectList must inherit a DirectSelectContainer!");
    }
    return context
        .dependOnInheritedWidgetOfExactType<_InheritedContainerListeners>()
        .listeners;
  }

回过头看看:

        _InheritedContainerListeners(
          listeners: this,
          child: widget.child,
        ),

listeners: this 直接指向 DirectSelectContainerState 本身。

child: widget.child 通用指向了 DirectSelectContainer#child,而 DirectSelectContainer#child 又是通过构造传入的,回过头看看 DirectSelectContainer 用在哪:

DirectSelectContainer(
        child: Column(
          children: <Widget>[
            SizedBox(height: 200.0),
            MealSelector(data: _meals, label: "To which meal?"),
          ],
        ),
      )

至于 MealSelector 开头有讲,它是一个 DirectSelectList<T> 的包装类 。

_InheritedContainerListeners 分析完成。

        Visibility(
          visible: isOverlayVisible,
          child: FadeTransition(
            opacity: fadeAnimationController
                .drive(CurveTween(curve: Curves.easeOut)),
            child: Column(
              children: <Widget>[
                Expanded(
                  child: Stack(
                    children: <Widget>[
                      _getListWidget(),
                      _getSelectionOverlayWidget(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        )

到目前为止

  • DirectSelectList<T>
  • DirectSelectItem<T>
  • DirectSelectContainer

这个三个主要类都大概的讲了一下。下面来简要概括一下:

DirectSelectList<T>: 说白了就是一个 controller,它具备捕捉用户操作(点击、滑动)和控制 List (菜单列表),虽然它叫做 DirectSelectList 但是内部并没有任何列表控件。

DirectSelectItem<T>: 单个菜单项目,其中主要靠 itemBuilder 来构建。在本案例 itemBuilder返回 Text

itemBuilder: (context, value) {
  return Text(value);
}

DirectSelectContainer:内部包含了一个 ListView ,对外提供接口以便于控制 ListView。

到现在 DirectSelectContainer 分析的差不多了,可以回到 DirectSelectList<T> 内部,接着分析滑动操作:

            onVerticalDragUpdate: (dragInfo) {
              if (!_isShowUpAnimationRunning) {
                _showListOverlay(dragInfo.primaryDelta);
              }
            },
            onVerticalDragEnd: (dragDetails) async {
              transitionEnded = true;
              _dragEnd();
            },
            onHorizontalDragEnd: (horizontalDetails) async {
              transitionEnded = true;
              _dragEnd();
            },

onVerticalDragUpdate,也就是垂直滑动回调,内部先判断动画状态(flag),然后调用 _showListOverla:

  _showListOverlay(double dy) {
    // 如果不可见,那么仅让他显示; 如果可见,那么调用拖拽
    if (!isOverlayVisible) {
      isOverlayVisible = true;
      onTapEventListener(widget, _getItemTopPosition(context));
    } else {
      onDragEventListener(dy);
    }
  }

onTapEventListener,onDragEventListener定义如下:

  Future Function(DirectSelectList, double) onTapEventListener;

  void Function(double) onDragEventListener;

这两个 Listener 在 _DirectSelectState<T>#didChangeDependencies 内赋值。

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final dsListener = DirectSelectContainer.of(context);
    assert(dsListener != null,
        "A DirectSelectList must inherit a DirectSelectContainer!");

    this.onTapEventListener = dsListener.toggleListOverlayVisibility;
    this.onDragEventListener = dsListener.performListDrag;
  }

首先通过 DirectSelectContainer.of(context) 来获取DirectSelectGestureEventListeners。然后赋值到 onTapEventListeneronDragEventListener

刚才分析过 DirectSelectGestureEventListeners 的实现类是 DirectSelectContainerState

分析一下这两个方法,我们显然能知道它的作用:

点击查看代码

  Future toggleListOverlayVisibility(
      final DirectSelectList visibleList, final double location) async {
    if (isOverlayVisible) {
      try {
        await _scrollController.animateTo(
          listPadding -
              _adjustedTopOffset +
              lastSelectedItem * _currentList.itemHeight(),
          duration: scrollToListElementAnimationDuration,
          curve: Curves.ease,
        );
      } catch (e) {} finally {
        _currentList.setSelectedItemIndex(lastSelectedItem);
        sleep(const Duration(milliseconds: 200));
        await fadeAnimationController.reverse();
        setState(() {
          _hideListOverlay();
        });
      }
    } else {
      setState(() {
        _showListOverlay(visibleList, location);
      });
    }
  }

  void performListDrag(double dragDy) {
    try {
      if (_scrollController != null && _scrollController.position != null) {
        final currentScrollOffset = _scrollController.offset;
        double allowedOffset = _allowedDragDistance(
            currentScrollOffset + _adjustedTopOffset,
            dragDy * widget.dragSpeedMultiplier);
        if (allowedOffset != 0.0) {
          _scrollController.jumpTo(currentScrollOffset + allowedOffset);

          final scrollPixels =
              _scrollController.offset - listPadding + _adjustedTopOffset;
          final selectedItemIndex = _getCurrentListElementIndex(scrollPixels);
          lastSelectedItem = selectedItemIndex;

          _performScaleTransformation(scrollPixels, selectedItemIndex);
        }
      }
    } catch (e) {
      print(e);
    }
  }

toggleListOverlayVisibility,用于控制列表是否显示。performListDrag 用于控制列表的滑动。

既然是控制滑动,那么它是使用 _scrollController 来控制的,_scrollController的定义如下:

    _scrollController = ScrollController(
      initialScrollOffset: listPadding -
          _currentScrollLocation +
          topOffset +
          _currentList.getSelectedItemIndex() * _currentList.itemHeight(),
    );

_scrollController 是作用于ListView:

return Container(
  decoration: dslContainerDecoration,
  child: ListView.builder(
	padding: EdgeInsets.only(left: paddingLeft),
	controller: _scrollController,
	itemCount: _currentList.items.length + 2,
	itemBuilder: (BuildContext context, int index) {
	  if (index == 0 || index == _currentList.items.length + 1) {
		return Container(height: listPadding);
	  }
	  final item = _currentList.items[index - 1];
	  final normalScale = 1.0;
	  if (lastSelectedItem == index - 1) {
		item.updateScale(_calculateNewScale(normalScale));
	  } else {
		item.updateScale(normalScale);
	  }
	  return item;
	},
  ),
);

在具体分析滑动逻辑之前,必须搞清 listview 于 scrollcontrol :

先从 listview 中的 itemBuilder 可以看出来,在列表的顶部和底部(index: 0_currentList.items.length + 1)都返回一个高度为 listPadding Container 。为什么要加两个 listpadding?是为了让菜单列表能在屏幕上任意位置显示,并可以滑动。为了方便理解,可以看看下面这个动图:

在第二张图listview 的开头也加入了类似listpadding的机制。

这里面用的了几个控制位置的变量,我用图表示:

把这个listview 背景改为其它颜色(绿)、把listpadding改为黑色我们就可以直观的看出来:

绿背景的listview

回过头,在 toggleListOverlayVisibility内,先判断list是否可见。

如果不可见调用 _showListOverlay 来显示 list。逻辑如下,没有什么复杂的地方:

点击查看代码

  /// 显示 list
  _showListOverlay(DirectSelectList visibleList, double location) async {
    _currentList = visibleList;
    _currentScrollLocation = location;
    lastSelectedItem = _currentList.getSelectedItemIndex();
    _currentList.items[lastSelectedItem].updateOpacity(1.0);
    isOverlayVisible = true;
    await fadeAnimationController.forward(from: 0.0);
  }

如果可见,那么通过 ScrollController 滑动到指定位置:

listPadding - _adjustedTopOffset + lastSelectedItem * _currentList.itemHeight()

下面来看一下滑动 (performListDrag) :

  void performListDrag(double dragDy) {
    try {
      if (_scrollController != null && _scrollController.position != null) {
        final currentScrollOffset = _scrollController.offset;
        double allowedOffset = _allowedDragDistance(
            currentScrollOffset + _adjustedTopOffset,
            dragDy * widget.dragSpeedMultiplier);

        if (allowedOffset != 0.0) {
          _scrollController.jumpTo(currentScrollOffset + allowedOffset);

          final scrollPixels =
              _scrollController.offset - listPadding + _adjustedTopOffset;
          final selectedItemIndex = _getCurrentListElementIndex(scrollPixels);
          lastSelectedItem = selectedItemIndex;

          _performScaleTransformation(scrollPixels, selectedItemIndex);
        }
      }
    } catch (e) {
      print(e);
    }
  }

其中调用了 _allowedDragDistance :

  double _allowedDragDistance(double currentScrollOffset, double position) {
    double newPosition = currentScrollOffset + position;
    double endOfListPosition =
        (_currentList.items.length - 1) * _currentList.itemHeight() +
            listPadding;
    if (newPosition < listPadding) {
      return listPadding - currentScrollOffset;
    } else if (newPosition > endOfListPosition) {
      return endOfListPosition - currentScrollOffset;
    } else {
      return position;
    }
  }

newPosition

计算新位置(newPosition)与最大位置(endOfListPosition),这里我们拆解一下 newPosition:

_scrollController.offset + _adjustedTopOffset + dragDy * widget.dragSpeedMultiplier

_scrollController.offset 、_adjustedTopOffset 在之前有解释。dragDy * widget.dragSpeedMultiplier 是指滑动偏移量×滑动速度 ,用一句通俗易懂得话来说它就是滑动距离。

其实,在默认情况、index为0、不滑动的状态下 newPosition = listpadding:

newPosition(初始、第一个item)

因为不滑动,所以 dragDy * widget.dragSpeedMultiplier 为 0,当我们向上滑动(item会向下滚动,逆着手指方向),dragDy * widget.dragSpeedMultiplier 会是负数,且 _scrollController.offset 减少所以nowPosition 会减少,变得小于 listPadding

double endOfListPosition =(_currentList.items.length - 1) * _currentList.itemHeight() + listPadding;
endOfListPosition

当我们向下滑动(item向上滚动),newPosition会增加,当滚动到最后一个item(_currentList.items.length - 1)时newPosition的图解如下:

newPosition(底部,_currentList.items.length – 1)

这时newPostion = endOfListPosition,如果继续向下滑动(item向上滚动),那么 dragDy * widget.dragSpeedMultiplier 会是正数, newPosition 会大于 endOfListPosition

最后回到 _allowedDragDistance 可以看到它用 if 将 newPosition 限制,listPadding < newPosition < endOfListPosition

  double _allowedDragDistance(double currentScrollOffset, double position) {
    double newPosition = currentScrollOffset + position;
    double endOfListPosition =
        (_currentList.items.length - 1) * _currentList.itemHeight() +
            listPadding;
    if (newPosition < listPadding) {
      return listPadding - currentScrollOffset;
    } else if (newPosition > endOfListPosition) {
      return endOfListPosition - currentScrollOffset;
    } else {
      return position;
    }
  }

我们已经分析过在什么情况下会触发 newPosition < listPaddingnewPosition > endOfListPosition

newPosition < listPadding 返回 return listPadding - currentScrollOffset。会将list限制在第一个item,不允许list向下滚动。

newPosition > endOfListPosition 返回 return endOfListPosition - currentScrollOffset; 会将list限制在最后一个item,不允许list向上滚动。

除此之外 return position;,直接返回 dragDy * widget.dragSpeedMultiplier这种情况不会限制listview滚动。

非顶部也非底部

好了,到此为止 _allowedDragDistance 分析完成。回到 performListDrag

//......        
        double allowedOffset = _allowedDragDistance(
          currentScrollOffset + _adjustedTopOffset,
          dragDy * widget.dragSpeedMultiplier,
        );

        if (allowedOffset != 0.0) {
        _scrollController.jumpTo(currentScrollOffset + allowedOffset);
        //...
        }
//......

调用_scrollController.jumpTo 使得listview滚动到相应位置。


August

Android 开发者、影视后期内容(包装)制作者、Unity 2D游戏开发者

0 条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注