Flutter InkWell 動畫淺析

升級之路發表於2018-12-21

Flutter InkWell 動畫淺析

背景

最近在開發 Flutter 專案過程中遇到了一個很有意思的 bug,如果頁面在 InkWell 動畫期間彈出一個 Dialog,那麼 InkWell 的動畫效果不會消失,如下圖右上角所示。以此為契機對 InkWell 的原始碼進行了探索和淺析

Flutter InkWell 動畫淺析

概述

InkWellFlutter 提供的一個用於實現 Material 觸控水波效果的 Widget,相當於 Android 裡的 Ripple

InkWell 繼承關係

圖片

InkWell 原始碼

class InkWell extends InkResponse {
  /// Creates an ink well.
  ///
  /// Must have an ancestor [Material] widget in which to cause ink reactions.
  ///
  /// The [enableFeedback] and [excludeFromSemantics] arguments must not be
  /// null.
  const InkWell({
    Key key,
    Widget child,
    ...省略
    bool enableFeedback = true,
    bool excludeFromSemantics = false,
  }) : super(
    key: key,
    child: child,
     ...省略
     containedInkWell: true,
     highlightShape: BoxShape.rectangle,
     ...省略
    enableFeedback: enableFeedback,
    excludeFromSemantics: excludeFromSemantics,
  );
}
複製程式碼

原始碼非常簡單,其實就是具有特定屬性值的 InkResponse,即 InkResponse 的特例

InkWell 顯示構成

Flutter InkWell 動畫淺析
顯示效果由 childhighlight 背景動畫和 splash 水波紋動畫構成

動畫分析基於 InkResponse

分析思路

從顯示效果來看,觸控 InkWell 之後動畫就啟動了,所以從 GestureDetector 入手

@override
Widget build(BuildContext context) {
  ...省略
  return GestureDetector(
    onTapDown: enabled ? _handleTapDown : null,
    onTap: enabled ? () => _handleTap(context) : null,
    onTapCancel: enabled ? _handleTapCancel : null,
    onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
    onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
    behavior: HitTestBehavior.opaque,
    child: widget.child,
    excludeFromSemantics: widget.excludeFromSemantics,
  );
}
複製程式碼

接著看 onTapDown 回撥函式,_createInkFeature(details)updateHighlight(true) 分別啟動了 splash 水波紋動畫和 highlight 背景動畫

void _handleTapDown(TapDownDetails details) {
  final InteractiveInkFeature splash = _createInkFeature(details);
  _splashes ??= HashSet<InteractiveInkFeature>();
  _splashes.add(splash);
  _currentSplash = splash;
  if (widget.onTapDown != null) {
    widget.onTapDown(details);
  }
  updateKeepAlive();
  updateHighlight(true);
}
複製程式碼

接著看 _createInkFeature(details) ,水波紋動畫是以觸控點為中心向周邊擴散的,_handleTapDown(TapDownDetails details) 的引數 TapDownDetails 提供了 pointer position; 這裡用 Android Studio 看原始碼有個坑,點內部的 create 方法會直接進入 InteractiveInkFeature 原始碼,實際上它是個父類,動畫實現是個空方法,真正實現 splash 水波紋動畫的是它的子類 InkSplash

InteractiveInkFeature _createInkFeature(TapDownDetails details) {
   ...省略
   splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create(
    referenceBox: referenceBox,
    position: position,
    ...省略
  );
  return splash;
}
複製程式碼

接著看 updateHighlight(true),實現 highlight 背景動畫的是 InkHighlight

void updateHighlight(bool value) {
    ...省略
    if (_lastHighlight == null) {
      final RenderBox referenceBox = context.findRenderObject();
      _lastHighlight = InkHighlight(
        controller: Material.of(context),
        referenceBox: referenceBox,
        ...省略
      updateKeepAlive();
    } else {
      _lastHighlight.activate();
    }
    ... 省略
  }
複製程式碼

動畫繪製

繼承關係

可以看出這倆其實是兄弟,他們有共同的祖先

圖片
圖片

接著看 InteractiveInkFeature,它定義了兩個空方法和實現了一個 ink colorgetset 方法,說明動畫相關的介面定義還在上級介面,即 InkFeature

abstract class InteractiveInkFeature extends InkFeature {
  ... 省略
  void confirm() {
  }
  void cancel() {
  }
  /// The ink's color.
  Color get color => _color;
  Color _color;
  set color(Color value) {
    if (value == _color)
      return;
    _color = value;
    controller.markNeedsPaint();
  }
}
複製程式碼

最終定位到關鍵介面方法就是 paintFeature(),接下來了解下 InkSplashInkHighlight 的具體實現

abstract class InkFeature {
  ...省略
  ///
  /// The transform argument gives the coordinate conversion from the coordinate
  /// system of the canvas to the coordinate system of the [referenceBox].
  @protected
  void paintFeature(Canvas canvas, Matrix4 transform);
}
複製程式碼

InkSplashInkHighlight

@override
void paintFeature(Canvas canvas, Matrix4 transform) {
  // 獲取背景色,_alpha 型別是 Animation<int>,splash 顏色由淺到深就是它控制的
  final Paint paint = Paint()..color = color.withAlpha(_alpha.value);
  // 水波紋效果中心點,由此向外擴散
  Offset center = _position;
  if (_repositionToReferenceBox)
    center = Offset.lerp(center, referenceBox.size.center(Offset.zero), _radiusController.value);
    // 矩陣變換
  final Offset originOffset = MatrixUtils.getAsTranslation(transform);
  canvas.save();
  if (originOffset == null) {
    canvas.transform(transform.storage);
  } else {
    canvas.translate(originOffset.dx, originOffset.dy);
  }
  // 定義水波紋邊界
  if (_clipCallback != null) {
    final Rect rect = _clipCallback();
    if (_customBorder != null) {
      canvas.clipPath(_customBorder.getOuterPath(rect, textDirection: _textDirection));
    } else if (_borderRadius != BorderRadius.zero) {
      canvas.clipRRect(RRect.fromRectAndCorners(
        rect,
        topLeft: _borderRadius.topLeft, topRight: _borderRadius.topRight,
        bottomLeft: _borderRadius.bottomLeft, bottomRight: _borderRadius.bottomRight,
      ));
    } else {
      canvas.clipRect(rect);
    }
  }
  // 獲取水波紋半徑大小,_radius 型別是 Animation<double>,水波紋擴散效果就是它的值由小到大變化造成的
  canvas.drawCircle(center, _radius.value, paint);
  canvas.restore();
}
複製程式碼

InkHighlight 相對比較簡單,實現原理和 InkSplash 是一樣的,只不過動畫只改變了顏色透明度,就不具體分析了

動畫開啟

文章開頭 InkWell 原始碼有這麼一句註釋,其實它是非常關鍵的資訊,通過跟蹤 InkFeaturepaintFeature() 方法的呼叫方可以發現結果指向 _MaterialState

 /// Must have an ancestor [Material] widget in which to cause ink reactions.
複製程式碼
class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController {
  ...省略
  List<InkFeature> _inkFeatures;

  // InkSplash、InkHighlight 建構函式末尾都呼叫 addInkFeature()
  @override
  void addInkFeature(InkFeature feature) {
    assert(!feature._debugDisposed);
    assert(feature._controller == this);
    _inkFeatures ??= <InkFeature>[];
    assert(!_inkFeatures.contains(feature));
    _inkFeatures.add(feature);
    markNeedsPaint();
  }

  // InkFeature dispose() 函式末尾呼叫 _removeFeature()
  void _removeFeature(InkFeature feature) {
    assert(_inkFeatures != null);
    _inkFeatures.remove(feature);
    markNeedsPaint();
  }
  
  @override
  void paint(PaintingContext context, Offset offset) {
    if (_inkFeatures != null && _inkFeatures.isNotEmpty) {
      final Canvas canvas = context.canvas;
      canvas.save();
      canvas.translate(offset.dx, offset.dy);
      canvas.clipRect(Offset.zero & size);
      // 迴圈遍歷所有的 InkFeature 並呼叫它們的 _paint() 繪製顯示效果
      for (InkFeature inkFeature in _inkFeatures)
        inkFeature._paint(canvas);
      canvas.restore();
    }
    super.paint(context, offset);
  }
}
複製程式碼
class _MaterialState extends State<Material> with TickerProviderStateMixin {
  final GlobalKey _inkFeatureRenderer = GlobalKey(debugLabel: 'ink renderer');
  ...省略
  @override
  Widget build(BuildContext context) {
      ...省略
      onNotification: (LayoutChangedNotification notification) {
        // _MaterialState build 的時候繪製了 splash 水波紋動畫和 highlight 背景動畫,這也就印證了註釋裡要求 InkWell 在繪製樹中必須有個 Material 祖先
        final _RenderInkFeatures renderer = _inkFeatureRenderer.currentContext.findRenderObject();
        renderer._didChangeLayout();
        return true;
      },
      child: _InkFeatures(
        key: _inkFeatureRenderer,
        color: backgroundColor,
        child: contents,
        vsync: this,
      )
    );
    ... 省略
  }
}
   
複製程式碼

動畫結束

動畫結束主要有兩種時機,回到 InkResponse 來看一段原始碼

class InkResponse extends StatefulWidget {
  ... 省略
  void _handleTap(BuildContext context) {
    _currentSplash?.confirm();
    _currentSplash = null;
    updateHighlight(false);
    if (widget.onTap != null) {
      if (widget.enableFeedback)
        Feedback.forTap(context);
      widget.onTap();
    }
  }

  void _handleTapCancel() {
    _currentSplash?.cancel();
    _currentSplash = null;
    if (widget.onTapCancel != null) {
      widget.onTapCancel();
    }
    updateHighlight(false);
  }

  void _handleDoubleTap() {
    _currentSplash?.confirm();
    _currentSplash = null;
    if (widget.onDoubleTap != null)
      widget.onDoubleTap();
  }

  void _handleLongPress(BuildContext context) {
    _currentSplash?.confirm();
    _currentSplash = null;
    if (widget.onLongPress != null) {
      if (widget.enableFeedback)
        Feedback.forLongPress(context);
      widget.onLongPress();
    }
  }

  @override
  void deactivate() {
    if (_splashes != null) {
      final Set<InteractiveInkFeature> splashes = _splashes;
      _splashes = null;
      for (InteractiveInkFeature splash in splashes)
        splash.dispose();
      _currentSplash = null;
    }
    assert(_currentSplash == null);
    _lastHighlight?.dispose();
    _lastHighlight = null;
    super.deactivate();
  }

  @override
  Widget build(BuildContext context) {
    ...省略
    return GestureDetector(
      onTapDown: enabled ? _handleTapDown : null,
      onTap: enabled ? () => _handleTap(context) : null,
      onTapCancel: enabled ? _handleTapCancel : null,
      onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
      onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
      behavior: HitTestBehavior.opaque,
      child: widget.child,
      excludeFromSemantics: widget.excludeFromSemantics,
    );
  }

}
複製程式碼
  • GestureDetector 回撥方法中直接或間接呼叫 InkFeaturedispose()
  • State 生命週期 deactivate() 方法 (應用返回後臺或者頁面跳轉會呼叫,彈出 Dialog 不會呼叫) 中直接或間接呼叫 InkFeaturedispose()

總結

  • InkWell 在響應 GestureDetectoronTapDown() 回撥時建立了 InkSplashInkHighlight (均是 InkFeature 的子類,各自實現了 paintFeature())
  • InkSplashInkHighlight 建立時將自己新增到 _RenderInkFeaturesInkFeature 佇列中
  • InkWellMaterial 祖先在 build() 的時候會呼叫 _RenderInkFeaturespaint()
  • _RenderInkFeaturespaint() 會遍歷 InkFeature 佇列並呼叫 InkFeaturepaintFeature() 繪製動畫效果
  • GestureDetector 回撥方法或 State 生命週期 deactivate() 方法直接或間接呼叫 InkFeaturedispose()
  • InkFeaturedispose() 將自己從 _RenderInkFeaturesInkFeature 佇列中移除,動畫效果結束

@123lxw123, 本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。

相關文章