23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterAdvent Calendar 2019

Day 11

GestureDetectorでSplitViewやMulti-Windowのような画面をつくる

Last updated at Posted at 2019-12-10

3行で

  • GestureDetectorの onVerticalDragUpdate でドラッグの位置が取得できる
  • 取得したドラッグの位置を ValueNotifier<double>ValueListenableBuilder<double> でWidgetの位置とサイズを調整する
  • SplitView や Multi-Window のような画面が実装できるが、ユーザビリティはアプリをインストールして各自で判断してください

画面の仕様

onVerticalDragUpdateでWidgetサイズを調整(英語の長文問題)
  • 黒いバーをドラッグして上下に移動させることで、上下のWidgetのサイズを変更する
  • 黒いバーをダブルタップすると、初期位置(中央)に黒いバーが戻る
  • 黒いバーの移動に合わせて、上下のWidgetのスクロール範囲を調整する
  • スマホのみで、回転対応とタブレットは非対応です :bow:

画面は英語の長文問題です。上が問題文で、下が設問文です。
問題と設問を同時に見ながら解きたいというニーズがあると思い実装しました。

実際に使いやすいかどうかは、アプリをインストールして体験してみてください。
この画面は無料で利用でき、iOSもAndroidも仕様は同じです。

実装のポイント

1. StackでListView.builderを重ねる

この画面は2つのListView.builderを重ねて実装しています。
上に重なっている側の画面(_downView)に黒いバーを実装しています。
そして、上下の画面に ValueListenableBuilder から伝達されるドラッグ位置(y)を渡してWidgetの位置やサイズを調整します。

Widget _createParagraphView({
  @required Paragraph paragraph,
}) {
  return ValueListenableBuilder<double>(
    valueListenable: _dragPositionNotifier,
    builder: (_, y, __) {
      return Stack(
        children: [
          _upView(paragraph: paragraph, y: y), // 上の画面
          _downView(paragraph: paragraph, y: y), // 下の画面
        ],
      );
    },
  );
}

2. paddingでスクロールの領域を調整する

上の画面は、複数の画像を表示するListView.builderが定義されているだけです。
サイズを調整する黒いバーは下の画面に実装されています。

上の画面は、黒いバーに従って下の画面の高さが変わるので、その値に従って上の画面のpaddingのbottomを調整します。
この調整をしないと、上の画面を下までスクロールしたとき表示する最後の画像が途中で切れてしまいます。
しかし、下の画面の高さ分だけbottomに余白をつくることで、スクロールしても画像が領域内に収まって表示されます。

上の画面
Widget _upView({
  @required Paragraph paragraph,
  @required double y,
}) {
  final downViewVisibleHeight = screenHeight(
        context,
        reducedBy: _dragPositionThreshold + _safeAreaBottomHeight,
      ) -
      y;
  return Container(
    color: Colors.white,
    child: ListView.builder(
      padding: EdgeInsets.only(
        top: 16,
        bottom: 8 + downViewVisibleHeight,
        left: 8,
        right: 8,
      ),
      shrinkWrap: true,
      scrollDirection: paragraph.scrollDirection,
      reverse: paragraph.isReverse,
      itemCount: paragraph.paragraphImageUrls.length,
      itemBuilder: (_, index) {
        return NetworkPlaceholderCacheImage.load(
          imageUrl: paragraph.paragraphImageUrls[index],
        );
      },
    ),
  );
}

3. Containerのtransformで下の画面の初期位置を決める

下の画面は上の画面にStackで重なったWidgetです。
下の画面には、Columnで「黒いバー」と「複数の画像を表示するListView.builder」を実装しています。

下の画面
Widget _downView({
  @required Paragraph paragraph,
  @required double y,
}) {
  return Container(
    transform: Matrix4.identity()..translate(0.0, y),
    color: Colors.white,
    child: Column(
      children: <Widget>[
        _dragBorderControl(),
        Expanded(
          child: ListView.builder(
            padding: EdgeInsets.only(
              top: 16,
              bottom: y,
              left: 8,
              right: 8,
            ),
            shrinkWrap: true,
            scrollDirection: paragraph.scrollDirection,
            reverse: paragraph.isReverse,
            itemCount: paragraph.questionImageUrls.length,
            itemBuilder: (_, index) {
              return NetworkPlaceholderCacheImage.load(
                imageUrl: paragraph.questionImageUrls[index],
              );
            },
          ),
        ),
      ],
    ),
  );
}

初期表示のために、Containerのtransformに Matrix4.identity()..translate(0.0, y) を渡します。
このときのyの初期値は「画面の高さの半分から黒いバーのドラッグ領域を省いた高さ(_dragDefaultPosition)」です。

画面の高さの半分から黒いバーのドラッグ領域を省いた高さ
_dragDefaultPosition = screenHeight(
      context,
      ratio: 0.5,
      reducedBy: _dragPositionThreshold,
    )
_dragPositionNotifier = ValueNotifier<double>(_dragDefaultPosition);

少し話はそれますが、screenHeightのメソッドは下記のブログで紹介されています。
画面サイズを軸に任意のサイズが欲しい場合は、とても便利な方法なので参考になります。

そして、下の画面でも黒いバーの移動分(上の画面の高さ分)だけ、下に余白を設けてスクロールしても画像が途中で切れてしまわないようにします。

4. GestureDetectorのonVerticalDragUpdateを黒いバーに定義する

黒いバーのレイアウトは、StackとContainerでデザインできます。

onDoubleTapは黒いバーをダブルタップすると、黒いバーを初期位置(_dragDefaultPosition)にする処理です。

onVerticalDragUpdateは、ドラック中の黒いバーの位置を取得する処理です。
_dragPositionThreshold は黒いバーの高さにドラックしやすく余分に領域をもたせた高さです。

この _dragPositionThreshold に従ってドラック中に画面外へ黒いバーが飛び出さないように、上下でバーの移動の限界値を調整します。

ドラッグで黒いバーをコントロール
Widget _dragBorderControl() {
  final borderControl = Stack(
    alignment: Alignment.center,
    children: <Widget>[
      Container(
        height: _dragPositionThreshold,
        color: Colors.black.withOpacity(0.7),
        child: Center(
          child: Container(
            width: 60,
            height: 4,
            decoration: BoxDecoration(
              color: Colors.white,
              border: Border.all(color: Colors.white),
              borderRadius: BorderRadius.circular(4),
            ),
          ),
        ),
      ),
      Container(
        height: 14,
        transform: Matrix4.identity()..translate(0.0, 14),
        decoration: const BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.vertical(
            top: Radius.circular(7),
          ),
        ),
      ),
      Container(
        height: 14,
        transform: Matrix4.identity()..translate(0.0, -14),
        decoration: const BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.vertical(
            bottom: Radius.circular(7),
          ),
        ),
      ),
    ],
  );

  return GestureDetector(
    onDoubleTap: () {
      _dragPositionNotifier.value = _dragDefaultPosition;
    },
    onVerticalDragUpdate: (drag) {
      final dy = screenHeight(context, reducedBy: drag.globalPosition.dy);
      if (dy > _dragPositionThreshold + _safeAreaBottomHeight &&
          dy < screenHeight(context, reducedBy: _dragPositionThreshold)) {
        _dragPositionNotifier.value =
            drag.globalPosition.dy - _dragPositionThreshold;
      }
    },
    child: borderControl,
  );
}

おわりに

当初、企画側からこの画面仕様がきたとき「面倒なことになりそうなだな」と思いました :sweat:

しかし、Flutterの表現力は凄まじく、1日もかからずiOSとAndroidの両方で実装できました :relaxed:

この画面のユーザビリティは賛否両論ありそうですが、企画側の意図を出来るだけ早く実現できるのは、Flutterの強みであり、それを実感できたプロジェクトでした。

私だけでなく、皆さんからも「Flutterでこんな画面を作れるぞ!作ったぞ!」という面白い企画や実装お待ちしております :muscle:

23
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?