コンサルティング事業 2021.01.08

Flutter × UI #04 ~ agexコーポレートサイトをトレースしてみた (スライダー編 part3) ~

これは、Flutterの楽しさを分かち合いたい1人のバックエンドエンジニアが、Flutterを広めるために行った、FlutterでのUI作成についての小さな物語です。

前回までのおはなし
Flutter × UI #01 ~ agexコーポレートサイトをトレースしてみた (ヘッダー編) ~
Flutter × UI #02 ~ agexコーポレートサイトをトレースしてみた (スライダー編 part1) ~
Flutter × UI #03 ~ agexコーポレートサイトをトレースしてみた (スライダー編 part2) ~

今回のおはなし
今回は、弊社HPの「スライダー」をFlutterで再現した際のおはなし part3です。

引きづづき、実装を進めていきます。

[ここまでの成果]
アイテムの形は平行四辺形
アイテムは水平方向に、一定間隔で配列されている
アイテムは一定時間間隔で自動的にスライドされる
アイテムのスライドは左方向
・ アイテムが最左端へスライドする際、アイテムのサイズが大きくなる
最左端のアイテムのみ、最右端へスライドする
・ 最右端へのスライド時、アイテムはフェードアウトアニメーションがついている
・ 最右端へのスライド時、アイテムは他要素の後ろ側を通過する
・ マウスがアイテム内へ移動すると、アイテム内画像がズームされる
・ マウスがアイテム外へ移動すると、アイテム内画像は元サイズへズームアウトされる
・ スライダー下部には、最左端アイテムについてのアイテム説明が配置される
・ アイテム説明は、アイテムのスライドに合わせてフェードアニメーションによって切り替わる
・ スライダー下部のアイテム説明右側に、スライドボタンが配置される
・ マウスがスライドボタン内に移動すると、スライドボタンは各方向に少し移動する
・ マウスがスライドボタン外に移動すると、スライドボタンは元の位置に移動する

再現してみる
アイテムが最左端へスライドする際、アイテムのサイズが大きくなる
前回の実装で、アイテムの現在位置を表すインデックス「pIndex」を定義しました。
この値が「0」となる際に、アイテムが最左端に移動するので、このタイミングでアイテムのサイズを変化させるように実装を進めます。

アイテムが最左端に移動した際の縦・横・オフセットサイズをそれぞれ、
・height : height + addedHeight
・width : width + addedWidth
・offset : offset * ((height + addedHeight) / height)
とし、通常時のアイテムサイズからの縦・横増加分を「addedHeight」「addedWidth」と定義します。

// アイテムの各パラメータ指定
double width = 200;
double height = 320;
double addedWidth = 50;
double addedHeight = 100;
double offset = 50;
int itemCount = /* 全アイテム数 */
int tIndex = itemCount;
int iIndex = /* 各アイテムの初期位置インデックス */
int pIndex = ((tIndex % itemCount) + iIndex) % itemCount; /* 要素の位置インデックス */
   
      ・
      ・
      ・

Stack(
  children: [
    AnimatedPositioned(
      key: ValueKey(iIndex),
      curve: Curves.easeOut,
      duration: Duration(seconds: 1),
      top: 0,
      left: pIndex == 0 ? 0 : addedWidth + ((width – offset) + 10) * pIndex, /* アイテム位置を、サイズ変化分調整 */
      child: SliderItem(
        height: pIndex == 0 ? height + addedHeight : height,
        width: pIndex == 0 ? width + addedWidth : width,
        offset: pIndex == 0 ? offset * ((height + addedHeight) / height) : offset,
        imagePath: “foo/bar”,
      ),
    ),
   
      ・
      ・
      ・

  ],
)

これにより、「アイテムが最左端へスライドする際、アイテムのサイズが大きくなる」アニメーションが実現できました。
しかし、このままではアイテムサイズ変化時にアニメーションがついていないので、「SliderItem」を少し変更します。

「AnimatedContainer」Widgetを利用し、サイズ変更時にアニメーションを追加します。

class SliderItem extends StatelessWidget {
  final double height;     // 高さ
  final double width;      // 幅
  final double offset;     // オフセット
  final String imagePath;  // 画像のパス

  const SliderItem(
      {Key key, this.height, this.width, this.offset, this.imagePath})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: Duration(seconds: 1),
      curve: Curves.easeOut,
      width: width,
      height: height,
      child: ClipPath(
        clipper: ParallelogramClipper(offset),
        child: Container(
          decoration: BoxDecoration(
            image: DecorationImage(
              fit: BoxFit.none,
              image: AssetImage(imagePath),
            ),
          ),
        ),
      ),
    );
  }
}

これにより、下記のようにサイズ変更時にアニメーションを追加することができました。

最右端へのスライド時、アイテムはフェードアウトアニメーションがついている
これについても、「SliderItem」に対して変更を加えていきます。
pIndexが最右端となる値に変化する際に、アイテムのopacityが変化するよう、「FadeTransition」Widgetを追加します。

// StatefulWidgetに変更
class SliderItem extends StatefulWidget {
  final bool isFade;       // 最右端:true / その他:false
  final int itemIndex;     // アイテムの初期位置インデックス(iIndex)
  final double width;      // 幅
  final double height;     // 高さ
  final double offset;     // オフセット
  final String imagePath;  // 画像のパス

  const SliderItem({Key key, this.isFade, this.itemIndex, this.width, this.height, this.offset, this.imagePath})
      : super(key: key);

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

class _SliderItemState extends State with TickerProviderStateMixin {
  AnimationController controller;
  Animation animation;
  Animation curvedAnimation;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 1), vsync: this);
    curvedAnimation = CurvedAnimation(parent: controller, curve: Curves.easeIn);
    animation = Tween(
      begin: 0.0,
      end: 1.0,
    ).animate(curvedAnimation);
  }

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

  @override
  Widget build(BuildContext context) {
    if (widget.isFade) {
      controller.reverse(from: 1);
    } else {
      controller.forward();
    }

    return AnimatedContainer(
      duration: Duration(seconds: 1),
      curve: Curves.easeOut,
      width: widget.width,
      height: widget.height,
      child: ClipPath(
        clipper: ParallelogramClipper(widget.offset),
        child: FadeTransition(
          key: ValueKey(widget.itemIndex),
          opacity: animation,
          child: Container(
            decoration: BoxDecoration(
              image: DecorationImage(
                fit: BoxFit.none,
                image: AssetImage(widget.imagePath),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

これにより、最右端への移動時にフェードアニメーションを追加することができます。

最右端へのスライド時、アイテムは他要素の後ろ側を通過する
ここまで実装してきたスライダーは、「Stack」Widgetを利用して作成してきました。
この「Stack」Widgetへ登録したWidgetのZ方向の重なりは、登録した順序に対応した重なりとなっており、
最初に配置したWidget → 最後に配置したWidgetの順序で、各Widgetの前側に重なるように配置されます。

そのため、「最右端へのスライド時、アイテムは他要素の後ろ側を通過する」ようにするためには、
最左端のWidgetが、「Stack」Widget内の一番最後に配置されている必要があります。

これを実現するために、「最右端のアイテム → 最左端のアイテム」の順で格納された配列を返す関数を定義し、
この関数から返される配列を「Stack」Widgetへと格納するように処理を変更します。

List _stackWidgets() {
  List newWidgets = [];
  List orgWidgets = [];

  for (int iIndex = 0; iIndex < itemCount; iIndex++) {
    int pIndex = ((tIndex % itemCount) + iIndex) % itemCount;
    orgWidgets.add(
      AnimatedPositioned(
        key: ValueKey(iIndex),
        curve: Curves.easeOut,
        duration: Duration(seconds: 1),
        top: 0,
        left: pIndex == 0
            ? 0
            : addedWidth +
                (width – offset + 10) * toDouble(),
        child: SliderItem(
          isFade: pIndex == itemCount – 1,
          itemIndex: iIndex,
          width: pIndex == 0
              ? width + addedWidth
              : width,
          height: pIndex == 0
              ? height + addedHeight
              : height,
          offset: pIndex == 0
              ? offset *
                  ((height + addedHeight) / height)
              : offset,
          imagePath: 'foo/bar',
        ),
      ),
    );
  }

  for (int iIndex = 0; iIndex < widget.itemCount; iIndex++) {
    int pIndex = ((tIndex % widget.itemCount) + iIndex) % widget.itemCount;
    widgets.add(orgWidgets[widget.itemCount – pIndex – 1]);
  }

  return widgets;
}

@override
Widget build(BuildContext context) {
  return Stack(
    children: [
      …_stackWidgets(),
    ],
  );
}

これにより、「最右端へのスライド時、他要素の後ろ側を通過する」を実装することができました。

これらの実装を組み合わせて、最終的にできあがったものがこちらです!!

(アイテムのオフセット値変化時のアニメーションがカクカクしていたので、SliderItemのClipPathまわりに修正を加えています。)

今回はここまでにします。なかなかヘビーでした。。

[ここまでの成果]
アイテムの形は平行四辺形
アイテムは水平方向に、一定間隔で配列されている
アイテムは一定時間間隔で自動的にスライドされる
アイテムのスライドは左方向
アイテムが最左端へスライドする際、アイテムのサイズが大きくなる
最左端のアイテムのみ、最右端へスライドする
最右端へのスライド時、アイテムはフェードアウトアニメーションがついている
最右端へのスライド時、アイテムは他要素の後ろ側を通過する
・ マウスがアイテム内へ移動すると、アイテム内画像がズームされる
・ マウスがアイテム外へ移動すると、アイテム内画像は元サイズへズームアウトされる
・ スライダー下部には、最左端アイテムについてのアイテム説明が配置される
・ アイテム説明は、アイテムのスライドに合わせてフェードアニメーションによって切り替わる
・ スライダー下部のアイテム説明右側に、スライドボタンが配置される
・ マウスがスライドボタン内に移動すると、スライドボタンは各方向に少し移動する
・ マウスがスライドボタン外に移動すると、スライドボタンは元の位置に移動する

ようやく半分のタスクを終えることができました。まだ半分。。。
ということで、次回も引き続きスライダーの実装です。

「アイテム内画像のズームイン・ズームアウト」について解説予定です。
最も難しい箇所の実装が完了しましたので、あとはテンポよく進めていこうと思います!

今日の一言
今回解説した「最右端へのスライド時、アイテムは他要素の後ろ側を通過する」の実装は何回も諦めかけました。。
全般動作確認しながら書いていますが、コードの分量が増えてきているので、記載ミスがないか心配(小声)

参考
https://stackoverflow.com/questions/55649155/flutter-clippath-animatedcontainer-path-not-animating-properly

この記事をシェアする
事業について詳しく知る

コンサルティング事業について詳しく知りたい方はこちら

RECOMMEND

おすすめ記事