Flutter交互實戰-即刻App探索頁下拉&拖拽效果

Flutter最近比較熱門,可是Flutter成體系的文章並很少,前期避免不了踩坑;我這篇文章主要介紹如何使用Flutter實現一個比較複雜的手勢交互,順便分享一下我在使用Flutter過程當中遇到的一些小坑,減小你們入坑;android

先睹爲快

本項目支持iosandroid平臺,效果以下ios

對了,順便分享一下生成gif的小竅門,建議用手機自帶錄屏功能導出mp4文件到電腦,而後電腦端用ffmpeg命令行處理,控制gif的質量和文件大小,個人建議是分辨率控制在270p,幀率在10左右;git

交互分析

看文章的小夥伴最好能手持即刻App,親自體驗一下探索頁的交互,是黃色Logo黃色主題色的即刻,人稱‘黃即’;github

即刻App原版功能有卡片旋轉,卡片撤回和卡片自動移除,時間關係暫時沒有實現,但核心的功能都在;bash

從一個Android開發者的習慣來看待,這個交互可拆份內外兩層控件,外層咱們須要一個總體下拉的控件,我稱爲下拉控件;內層咱們須要實現一個上、下、左、右四方向拖拽移動的控件,咱們稱爲卡片控件下拉控件卡片控件不只要處理手勢,還須要處理子Widget的佈局;下面我再分析細節功能:app

下拉控件:ide

  • 子控件從上到下豎直襬放,頂部菜單默認隱藏在屏幕外
  • 下拉手勢全部子控件下移,菜單視覺差效果
  • 支持點擊自動展開、收起效果

卡片控件佈局

  • 卡片層疊佈局,錯落有致
  • 最上層卡片支持手勢拖拽
  • 其餘卡片響應拖拽小幅位移
  • 鬆手移除卡片

碼上入手

熱身

套用App開發伎倆,實現上面的交互無非就是控件佈局和手勢識別。固然Flutter開發也是這些套路,只不過萬物皆是Widget,在Flutter中經常使用的基本佈局有ColumnRowStack等,手勢識別有ListenerGestureDetectorRawGestureDetector等,這是本文重點講解的控件,不限於上面這幾個Widget,由於Flutter提供的Widget太多了,重點的控件須要牢記外,其餘時候真是現用現查;學習

因此下面咱們從佈局和手勢這兩個大的技術點,來一一擊破功能點;測試

佈局擺放

這裏所謂的佈局,包括Widget的尺寸大小和位置的控制,通常都是父Widget掌管子Widget的命運,Flutter就是一層一層Widget嵌套,不要擔憂,下面從外到內具體案例講解;

下拉控件

首先咱們要實現最外層佈局,效果是:子Widget豎直襬放,且最上面的Widget默認須要擺放在屏幕外;

如上圖所示,紅色區域是屏幕範圍,header是頭部隱藏的菜單佈局,content是卡片佈局的主體;

先說入的坑

豎直佈局我最早想到的是Column,我想要的效果是content高度和父Widget的高度一致,我首先想到是讓Expanded包裹content,結果是content的高度永遠等於Column高度減header高度,形成現象就是content高度不填充,或者是擠壓現象,若是繼續使用Colunm可能就得放棄Expanded,手動給content賦值高度,沒準是個辦法,但我不肯意手動賦值content的高度,太不優雅了,最後果斷棄用Column

另外一個問題是如何隱藏header,我想到兩種方案:

  1. 採用外層Transform包裹整個佈局,內層Transform包裹header,而後賦值內層dy = -headerHeight,隨着手勢下拉動態,並不改變headerTransform,而是改變最外層Transformdy
  2. 動態改變header高度,初始高度爲0,隨着手勢下拉動態計算;

可是上面這兩種都有坑,第一種方式會影響控件的點擊事件,onTap方法不會被回調;第二種因爲高度在不斷改變,會影響header內部子Widget的佈局,很難作視覺差的控制;

最終方案

最後採用Stack來佈局,經過Stack配合Positioned,實現header佈局在屏幕外,並且能夠作到讓content佈局填充父Widget;

PullDragWidget

Widget build(BuildContext context) {
  return RawGestureDetector(
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,
      child: Stack(
        children: <Widget>[
          Positioned(//content佈局
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(////header佈局
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
複製代碼

首先解釋一下Positioned的基本用法,topbottomheight控制高度和位置,並且兩兩配合使用,topbottom能夠理解成marginTop和marginBottom,height顧名思義是直接Widget的高度,若是top配置bottom,意味着高度等於parentHeight-top-bottom,若是top/bottom配合height使用,高度通常是固定的,固然topbottom是接受負數的;

再分析代碼,首先_offsetY是下拉距離,是一個改變的量初始值爲0,content須要設置top = _offsetYbottom = -_offsetY,改變的是上下位置,高度不會改變;同理,header是採用topheight控制,高度固定,只須要動態改變top便可;

用Flutter寫佈局真的很簡單,我極力推崇使用Stack佈局,由於它比較靈活,沒有太多的限制,用好Stack主要還得用好Positioned,學好它沒錯;

卡片控件

卡片實現的效果就是依次層疊,錯落有致,這個很容易想到Stack來實現,固然有了上面踩坑,用Stack算是很輕鬆了;

重疊的效果使用Stack很簡單,錯落有致的效果實在起來可能性就比較多了,好比可使用Positioned,也能夠包裹Container改變margin或者padding,可是考慮到角度的旋轉,我選擇使用Transform,由於Transform不只能夠玩轉位移,還有角度和縮放等,其內部其實是操做一個矩陣變換;Transform挺好用,可是在Transform多層嵌套的某些特殊狀況下,會存在不響應onTap事件的狀況,我想這應該是Transform的bug,拖拽事件暫時沒有發現問題,這個是否是bug有待確認,暫時不影響使用;

CardStackWidget

Widget build(BuildContext context) {
  if (widget.cardList == null || widget.cardList.length == 0) {
    return Container();
  }
  List<Widget> children = new List();
  int length = widget.cardList.length;
  int count = (length > widget.cardCount) ? widget.cardCount : length;
  for (int i = 0; i < count; i++) {
    double dx = i == 0 ? _totalDx : -_ratio * widget.offset;
    double dy = i == 0 ? _totalDy : _ratio * widget.offset;
    Widget cardWidget = _CardWidget(
      cardEntity: widget.cardList[i],
      position: i,
      dx: dx,
      dy: dy,
      offset: widget.offset,
    );
    if (i == 0) {
      cardWidget = RawGestureDetector(
        gestures: _cardGestures,
        behavior: HitTestBehavior.deferToChild,
        child: cardWidget,
      );
    }
    children.add(Container(
      child: cardWidget,
      alignment: Alignment.topCenter,
      padding: widget.cardPadding,
    ));
  }
  return Stack(
    children: children.reversed.toList(),
  );
}
複製代碼

_CardWidget

Widget build(BuildContext context) {
  return AspectRatio(
    aspectRatio: 0.75,
    child: Transform(
        transform: Matrix4.translationValues(
            dx + (offset * position.toDouble()),
            dy + (-offset * position.toDouble()),
            0),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(10),
          child: Stack(
            fit: StackFit.expand,
            children: <Widget>[
              Image.network(
                cardEntity.picUrl,
                fit: BoxFit.cover,
              ),
              Container(color: const Color(0x5a000000)),
              Container(
                margin: EdgeInsets.all(20),
                alignment: Alignment.center,
                child: Text(
                  cardEntity.text,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                      letterSpacing: 2,
                      fontSize: 22,
                      color: Colors.white,
                      fontWeight: FontWeight.bold),
                  maxLines: 4,
                ),
              )
            ],
          ),
        )),
  );
}
複製代碼

簡單總結一下卡片佈局代碼,CardStackWidget是管理卡片Stack的父控件,負責對每一個卡片進行佈局,_CardWidget是對單獨卡片內部進行佈局,整體來講沒有什麼難點,細節控制邏輯是在對上層_CardWidget和底層_CardWidget偏移量的計算;

佈局的內容就講這麼多,總體來講仍是比較簡單,所謂的有些坑也不必定算是坑,只是不適應某些應用場景罷了;

手勢識別

Flutter手勢識別最經常使用的是ListenerGestureDetector這兩個Widget,其中Listener主要針對原始觸摸點進行處理,GestureDetector已經對原始觸摸點加工成了不一樣的手勢;這兩個類的方法介紹以下;

Listener

Listener({
  Key key,
  this.onPointerDown, //手指按下回調
  this.onPointerMove, //手指移動回調
  this.onPointerUp,//手指擡起回調
  this.onPointerCancel,//觸摸事件取消回調
  this.behavior = HitTestBehavior.deferToChild, //在命中測試期間如何表現
  Widget child
})
複製代碼

GestureDetector手勢回調:

Property/Callback Description
onTapDown 用戶每次和屏幕交互時都會被調用
onTapUp 用戶中止觸摸屏幕時觸發
onTap 短暫觸摸屏幕時觸發
onTapCancel 用戶觸摸了屏幕,可是沒有完成Tap的動做時觸發
onDoubleTap 用戶在短期內觸摸了屏幕兩次
onLongPress 用戶觸摸屏幕時間超過500ms時觸發
onVerticalDragDown 當一個觸摸點開始跟屏幕交互,同時在垂直方向上移動時觸發
onVerticalDragStart 當觸摸點開始在垂直方向上移動時觸發
onVerticalDragUpdate 屏幕上的觸摸點位置每次改變時,都會觸發這個回調
onVerticalDragEnd 當用戶中止移動,這個拖拽操做就被認爲是完成了,就會觸發這個回調
onVerticalDragCancel 用戶忽然中止拖拽時觸發
onHorizontalDragDown 當一個觸摸點開始跟屏幕交互,同時在水平方向上移動時觸發
onHorizontalDragStart 當觸摸點開始在水平方向上移動時觸發
onHorizontalDragUpdate 屏幕上的觸摸點位置每次改變時,都會觸發這個回調
onHorizontalDragEnd 水平拖拽結束時觸發
onHorizontalDragCancel onHorizontalDragDown沒有成功完成時觸發
onPanDown 當觸摸點開始跟屏幕交互時觸發
onPanStart 當觸摸點開始移動時觸發
onPanUpdate 屏幕上的觸摸點位置每次改變時,都會觸發這個回調
onPanEnd pan操做完成時觸發
onScaleStart 觸摸點開始跟屏幕交互時觸發,同時會創建一個焦點爲1.0
onScaleUpdate 跟屏幕交互時觸發,同時會標示一個新的焦點
onScaleEnd 觸摸點再也不跟屏幕有任何交互,同時也表示這個scale手勢完成

ListenerGestureDetector如何抉擇,首先GestureDetector是基於Listener封裝,它解決了大部分手勢衝突,咱們使用GestureDetector就夠用了,可是GestureDetector不是萬能的,必要時候須要自定義RawGestureDetector

另一個很重要的概念,Flutter手勢事件是一個從內Widget向外Widget的冒泡機制,假設內外Widget同時監聽豎直方向的拖拽事件onVerticalDragUpdate,每每都是內層控件得到事件,外層事件被動取消;這樣的概念和Android父佈局攔截機制就徹底不一樣了;

雖然Flutter沒有外層攔截機制,可是彷佛還有一線但願,那就是IgnorePointerAbsorbPointerWidget,這倆哥們能夠忽略或者阻止子Widget樹不響應Event;

手勢分析

基本原理介紹完了,接下來分析案例交互,上面說了我把總體佈局拆分紅了下拉控件和卡片控件,分析即刻App的拖拽的行爲:當下拉控件沒有展開下拉菜單時,卡片控件是能夠響應上、左、右三個方向的手勢,下拉控件只響應一個向下方向的手勢;當下拉菜單展開時,卡片不能響應任何手勢,下拉控件能夠響應豎直方向的全部事件;

上圖更加形象解釋兩種狀態下的手勢響應,下拉控件是父Widget,卡片控件是子Widget,因爲子Widget能優先響手勢,因此在初始階段,咱們不能讓子Widget響應向下的手勢;

因爲GestureDetector只封裝水平和豎直方向的手勢,且兩種手勢不能同時使用,咱們從GestureDetector源碼來看,能不能封裝一個監聽不一樣四個方向的手勢,;

GestureDetector

final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};

if (onVerticalDragDown != null ||
    onVerticalDragStart != null ||
    onVerticalDragUpdate != null ||
    onVerticalDragEnd != null ||
    onVerticalDragCancel != null) {
  gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
    () => VerticalDragGestureRecognizer(debugOwner: this),
    (VerticalDragGestureRecognizer instance) {
      instance
        ..onDown = onVerticalDragDown
        ..onStart = onVerticalDragStart
        ..onUpdate = onVerticalDragUpdate
        ..onEnd = onVerticalDragEnd
        ..onCancel = onVerticalDragCancel;
    },
  );
}

return RawGestureDetector(
  gestures: gestures,
  behavior: behavior,
  excludeFromSemantics: excludeFromSemantics,
  child: child,
);
複製代碼

GestureDetector最終返回的是RawGestureDetector,其中gestures是一個map,豎直方向的手勢在VerticalDragGestureRecognizer這個類;

VerticalDragGestureRecognizer

class VerticalDragGestureRecognizer extends DragGestureRecognizer {
  /// Create a gesture recognizer for interactions in the vertical axis.
  VerticalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;

  @override
  Offset _getDeltaForDetails(Offset delta) => Offset(0.0, delta.dy);

  @override
  double _getPrimaryValueFromOffset(Offset value) => value.dy;

  @override
  String get debugDescription => 'vertical drag';
}
複製代碼

VerticalDragGestureRecognizer繼承DragGestureRecognizer,大部分邏輯都在DragGestureRecognizer中,咱們只關注重寫的方法:

  • _hasSufficientPendingDragDeltaToAccept方法是關鍵邏輯,控制是否接受該拖拽手勢
  • _getDeltaForDetails返回拖拽進度的dx、dy偏移量
  • _getPrimaryValueFromOffset返回單方向手勢value,不一樣方向(同時擁有水平和豎直)的能夠傳null
  • _isFlingGesture是否該手勢的Fling行爲

自定義DragGestureRecognizer

想實現接受三個方向的手勢,自定義DragGestureRecognizer是一個好的思路;我但願接受上、下、左、右四個方向的參數,根據參數不一樣監聽不一樣的手勢行爲,照葫蘆畫瓢自定義一個接受方向的GestureRecognizer

DirectionGestureRecognizer

class DirectionGestureRecognizer extends _DragGestureRecognizer {
  int direction;
  //接受中途變更
  ChangeGestureDirection changeGestureDirection;
  //不一樣方向
  static int left = 1 << 1;
  static int right = 1 << 2;
  static int up = 1 << 3;
  static int down = 1 << 4;
  static int all = left | right | up | down;

  DirectionGestureRecognizer(this.direction,
      {Object debugOwner})
      : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    if (_hasAll) {
      return estimate.pixelsPerSecond.distanceSquared > minVelocity &&
          estimate.offset.distanceSquared > minDistance;
    } else {
      bool result = false;
      if (_hasVertical) {
        result |= estimate.pixelsPerSecond.dy.abs() > minVelocity &&
            estimate.offset.dy.abs() > minDistance;
      }
      if (_hasHorizontal) {
        result |= estimate.pixelsPerSecond.dx.abs() > minVelocity &&
            estimate.offset.dx.abs() > minDistance;
      }
      return result;
    }
  }

  bool get _hasLeft => _has(DirectionGestureRecognizer.left);

  bool get _hasRight => _has(DirectionGestureRecognizer.right);

  bool get _hasUp => _has(DirectionGestureRecognizer.up);

  bool get _hasDown => _has(DirectionGestureRecognizer.down);
  bool get _hasHorizontal => _hasLeft || _hasRight;
  bool get _hasVertical => _hasUp || _hasDown;

  bool get _hasAll => _hasLeft && _hasRight && _hasUp && _hasDown;

  bool _has(int flag) {
    return (direction & flag) != 0;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    // if (_hasAll) {
    //   return _pendingDragOffset.distance > kPanSlop;
    // }
    bool result = false;
    if (_hasUp) {
      result |= _pendingDragOffset.dy < -kTouchSlop;
    }
    if (_hasDown) {
      result |= _pendingDragOffset.dy > kTouchSlop;
    }
    if (_hasLeft) {
      result |= _pendingDragOffset.dx < -kTouchSlop;
    }
    if (_hasRight) {
      result |= _pendingDragOffset.dx > kTouchSlop;
    }
    return result;
  }

  @override
  Offset _getDeltaForDetails(Offset delta) {
    if (_hasAll || (_hasVertical && _hasHorizontal)) {
      return delta;
    }

    double dx = delta.dx;
    double dy = delta.dy;

    if (_hasVertical) {
      dx = 0;
    }
    if (_hasHorizontal) {
      dy = 0;
    }
    Offset offset = Offset(dx, dy);
    return offset;
  }

  @override
  double _getPrimaryValueFromOffset(Offset value) {
    return null;
  }

  @override
  String get debugDescription => 'orientation_' + direction.toString();
}
複製代碼

因爲DragGestureRecognizer的不少方法是私有的,想從新只能copy一份代碼出來,而後重寫主要的方法,根據不一樣入參處理不一樣的手勢邏輯;

注意事項

敲黑板了,在自定義DragGestureRecognizer時:_getDeltaForDetails返回值表示dxdy的偏移量,在只存在水平或者只存在豎直方向的狀況下,須要將另外一個方向的dxdy置0;

當前Widget樹有且只存在一個手勢時,手勢判斷的邏輯_hasSufficientPendingDragDeltaToAccept可能不會被調用,這時候必定要重寫_getDeltaForDetails控制返回dxdy

如何使用

自定義的DirectionGestureRecognizer能夠配置leftrightupdown四個方向的手勢,並且支持不一樣方向的組合;

好比咱們只想監聽豎直向下方向,就建立DirectionGestureRecognizer(DirectionGestureRecognizer.down)的手勢識別;

想監聽上、左、右的手勢,建立DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up)的手勢識別;

DirectionGestureRecognizer就像一把磨刀石,刀已經磨鋒利,砍材就很輕鬆了,下面進行控件的手勢實現;

下拉控件手勢

PullDragWidget

_contentGestures = {
//向下的手勢
  DirectionGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.down),
          (instance) {
    instance.onDown = _onDragDown;
    instance.onStart = _onDragStart;
    instance.onUpdate = _onDragUpdate;
    instance.onCancel = _onDragCancel;
    instance.onEnd = _onDragEnd;
  }),
  //點擊的手勢
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onContentTap;
  })
};

Widget build(BuildContext context) {
  return RawGestureDetector(//返回RawGestureDetector
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,//手勢在此
      child: Stack(
        children: <Widget>[
          Positioned(
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
複製代碼

PullDragWidget是下拉拖拽控件,根Widget是一個RawGestureDetector用來監聽手勢,其中gestures支持向下拖拽和點擊兩個手勢;當下拉控件處於_opened狀態說header已經拉下來,此時配合IgnorePointer,禁用子Widget全部的事件監聽,天然內部的卡片就響應不了任何事件;

卡片控件手勢

同下拉控件同樣,卡片控件只須要監聽其他三個方向的手勢,便可完成任務:

CardStackWidget

_cardGestures = {
  DirectionGestureRecognizer://監聽上左右三個方向
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.left |
              DirectionGestureRecognizer.right |
              DirectionGestureRecognizer.up), (instance) {
    instance.onDown = _onPanDown;
    instance.onUpdate = _onPanUpdate;
    instance.onEnd = _onPanEnd;
  }),
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onCardTap;
  })
};
複製代碼

手勢答疑

  • 爲何不用 onPanDown onPanUpdate onPanEnd 來拖動?

這是掘金評論提的問題,我解答一下:在GestureDetector中有Pan手勢和Drag手勢,這兩個手勢都能用處拖拽的場景,但不一樣的是Drag手勢僅限於水平豎直方向的監聽,Pan手勢不約束方向任意方向都能監聽,除此以外觸發條件也不一致,Pan手勢的觸發條件是滑動動屏幕的距離distance大於kTouchSlop*2Drag手勢的觸發條件是dx或者dy大於kTouchSlopdxdydistance造成勾股定理的三個邊長;假設一樣在監聽豎直滑動這種場景,VerticalDrag老是比Pan先觸發;若是下拉控件用VerticalDrag卡片控件用Pan,下拉控件會優先獲取向上的拖拽,卡片控件就會失去向上拖拽的機會,這就實現不了交互了,退一步即便Pan的觸發條件跟VerticalDrag同樣,因爲Flutter的事件傳遞是從內到外的,這會致使外層下拉控件徹底失去響應機會。以上個人我的理解,若有誤導還請大佬評論指正。

手勢小結

分析Flutter手勢冒泡的特性,父Widget既沒有響應事件的優先權,也沒有監聽單獨方向(leftrightupdown)的手勢,只能本身想辦法自定義GestureRecognizer,把本來VerticalHorizontal兩個方向的手勢識別擴展成leftrightupdown四個方向,區分開會產生衝突的手勢;

固然也可能有其餘的方案來實現該交互的手勢識別,條條大路通羅馬,我只是拋磚引玉,你們有好的方案能夠積極留言提出寶貴意見;

總結

知識點

因爲篇幅有限並無介紹完該交互的全部內容,深表遺憾,總結概括一下代碼中用到的知識點:

  • ColumnRowExpandedStackPositionedTransform等Widget的使用;
  • GestureDetectorRawGestureDetectorIgnorePointer等Widget的使用;
  • 自定義GestureRecognizer實現自定義手勢識別;
  • AnimationControllerTween等動畫的使用;
  • EventBus的使用;

最後

上面章節主要介紹在當前場景下用Flutter佈局和手勢的實戰技巧,其中更深層次手勢競技和分發的源碼級分析,有機會再作深刻學習和分享;

另外本篇並非按部就班的零基礎入門,對剛接觸的同窗可能感受有點懵,可是沒有關係,建議你clone一份代碼跑起來效果,沒準就能提起本身學習的興趣;

最最後,本篇全部代碼都是開源的,你的點贊是對我最大的鼓勵。

項目地址:github.com/HitenDev/Fl…

相關文章
相關標籤/搜索