【Flutter】インスタのストーリーのUIの実装をしてみた

株式会社Neverの近藤です。

株式会社Neverはモバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽に[こちら](https://neverjp.com/contact/
)へお問合せください。

今日はFlutterでインスタのストーリーのようなUIの作成について解説します。

# 目次
[1.成果物](#1-成果物)
[2.概要](#2-概要)
[3.実装手順](#3-実装手順)
[4.まとめ](#4-まとめ)
[5.おわりに](#5-おわりに)

# 成果物

**ストーリーのヘッダーと次の画像の表示**
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/252e5b5d-4544-a1b6-7dc4-c54b422f50e5.gif" width="480">

**メッセージ入力時**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/6b270084-e77f-10f6-69c7-ce95c0514e8c.gif" width="480">

**複数ユーザのストーリーの表示**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/7b9859e8-fb64-b894-3aa9-c3f7fbd6bb78.gif" width="480">


# 概要
インスタのストーリーのようなUIを今回は実装してみました。
簡単に実装できる素敵なライブラリーはありますが、今回は基本的に使用せずにUIの実装を行いました。

# 実装手順

## ストーリーを表示するデータモデル

今回は画像か動画を判定するための`MediaType`と`url`を持った`ContentData`というデータモデルのクラスを作成しています。

```dart
// 画像か動画かを判別するためのenum
enum MediaType {
  image,
  video,
}

// ストーリーのコンテンツに関するデータクラス
class ContentData extends Equatable {
  const ContentData({
    required this.url,
    required this.media,
  });

  final String url;
  final MediaType media;

  @override
  List<Object?> get props => [url, media];
}
```

また、各ユーザがストーリーの内容を保持するためにContentDataを使用する必要があるため、`StoryData`というデータモデルのクラスも作成しました。


```dart
// ストーリーに関するデータクラス
class StoryData extends Equatable {
  const StoryData({
    required this.contentData,
    required this.userName,
  });

  final List<ContentData> contentData;
  final String userName;

  @override
  List<Object?> get props => [contentData, userName];
}
```


※ ContentDataについてはの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/stories/models/content_data.dart)
※ StoryDataについてはの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/stories/models/story_data.dart)

## 画像または動画の表示とメッセージ入力欄のWidget

ストーリーの表示は、画像または動画の表示とメッセージ入力欄のWidgetを用意する必要があります。
画像または動画を表示するWidgetとメッセージ入力欄のWidgetは後に解説します。

また、ストーリーの表示中に画面を長押しをした時にメッセージ入力欄を非表示にする実装も`AnimatedOpacity`を使用して行なっています。


ストーリーを表示するWidgetの全体の実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/stories/screens/widgets/content.dart)

```dart
// 画像や動画を表示するWidget
class Content extends StatefulWidget {
  const Content({
    super.key,
    required this.contentData,
    required this.focusNode,
    required this.textController,
    required this.videoController,
    required this.isLongPress,
  });

  final ContentData contentData;
  final FocusNode focusNode;
  final TextEditingController textController;
  final VideoPlayerController? videoController;
  final bool isLongPress;

  @override
  State<Content> createState() => _ContentState();
}

class _ContentState extends State<Content> {
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Container(
          constraints: const BoxConstraints.expand(),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(8),
            child: () {
              switch (widget.contentData.media) {
                case MediaType.image:
                  return Container(
                    constraints: const BoxConstraints.expand(),
                    child: Image(
                      image: NetworkImage(widget.contentData.url),
                      fit: BoxFit.fitWidth,
                    ),
                  );
                case MediaType.video:
                  final videoController = widget.videoController;
                  if (videoController == null ||
                      !videoController.value.isInitialized) {
                    return const SizedBox.shrink();
                  }
                  return FittedBox(
                    fit: BoxFit.fitWidth,
                    child: SizedBox(
                      width: videoController.value.size.width,
                      height: videoController.value.size.height,
                      child: VideoPlayer(videoController),
                    ),
                  );
              }
            }(),
          ),
        ),
        AnimatedOpacity(
          opacity: widget.isLongPress ? 0 : 1,
          duration: const Duration(milliseconds: 500),
          child: MessageArea(
            focusNode: widget.focusNode,
            textController: widget.textController,
          ),
        ),
      ],
    );
  }
}
```


### 画像または動画を表示するWidget

画像または動画を表示するストーリーの画面のUIを作成していきます。
動画の再生や停止には[video_player](https://pub.dev/packages/video_player)を使用しました。
MediaTypeが`image`の場合は`NetworkImage`で表示を行い、`video`の場合は`VideoPlayer`を使用して表示しています。
また、ClipRRectで画像や動画の角を丸くしています。

```dart
Container(
  constraints: const BoxConstraints.expand(),
  child: ClipRRect(
    borderRadius: BorderRadius.circular(8),
    child: () {
      switch (widget.contentData.media) {
        case MediaType.image:
          return Container(
            constraints: const BoxConstraints.expand(),
            child: Image(
              image: NetworkImage(widget.contentData.url),
              fit: BoxFit.fitWidth,
            ),
          );
        case MediaType.video:
          final videoController = widget.videoController;
          if (videoController == null ||
              !videoController.value.isInitialized) {
            return const SizedBox.shrink();
          }
          return FittedBox(
            fit: BoxFit.fitWidth,
            child: SizedBox(
            width: videoController.value.size.width,
            height: videoController.value.size.height,
            child: VideoPlayer(videoController),
            ),
          );
      }
    }(),
  ),
),
```

### ストーリーに対してメッセージ入力欄のWidget
インスタのストーリーではTextFieldがフォーカスされているか否か、メッセージが入力されているか否かで、少しUIが変わるのでその部分も実装しました。

メッセージ入力欄の全体の実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/stories/screens/widgets/message_area.dart)

```dart
Stack(
  fit: StackFit.expand,
  children: [
    Positioned(
      bottom: 0,
      right: 0,
      left: 0,
      child: Row(
        children: <Widget>[
          Flexible(
            child: Padding(
              padding: const EdgeInsets.all(
                8,
              ),
              child: TextField(
                focusNode: widget.focusNode,
                controller: widget.textController,
                maxLines: 4,
                minLines: 1,
                decoration: InputDecoration(
                  contentPadding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 16,
                  ),
                  hintText: 'メッセージを送信',
                  hintStyle:
                      Theme.of(context).textTheme.bodyLarge?.copyWith(
                            color: Colors.white,
                            fontSize: 16,
                          ),
                  enabledBorder: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(24),
                    borderSide: const BorderSide(color: Colors.white),
                  ),
                  focusedBorder: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(24),
                    borderSide: const BorderSide(color: Colors.white),
                  ),
                  // フォーカスがされているか否か、テキストが入力されているか否か
                  suffixIcon: widget.focusNode.hasFocus
                      ? hasText
                          ? TextButton(
                              onPressed: () {
                                // バックエンド等にメッセージ送信する
                                debugPrint(widget.textController.text);
                              },
                              child: Text(
                                '送信',
                                style: Theme.of(
                                  context,
                                ).textTheme.bodyLarge?.copyWith(
                                      color: Colors.white,
                                      fontWeight: FontWeight.bold,
                                      fontSize: 16,
                                    ),
                              ),
                            )
                          : IconButton(
                              onPressed: () {},
                              icon: const Icon(
                                Icons.gif_box_outlined,
                                color: Colors.white,
                                size: 32,
                              ),
                            )
                      : const SizedBox.shrink(),
                ),
                style: Theme.of(context)
                    .textTheme
                    .bodyLarge
                    ?.copyWith(color: Colors.white, fontSize: 16),
                onChanged: (value) {
                  setState(() {
                    hasText = value.isNotEmpty;
                  });
                },
              ),
            ),
          ),
          // フォーカスがされているか否か
          widget.focusNode.hasFocus
              ? const SizedBox.shrink()
              : Row(
                  children: [
                    IconButton(
                      icon: const Icon(
                        Icons.favorite_border,
                        color: Colors.white,
                      ),
                      onPressed: () => {},
                    ),
                    IconButton(
                      icon: const Icon(
                        Icons.send,
                        color: Colors.white,
                      ),
                      onPressed: () => {
                        // バックエンド等にメッセージ送信する
                        debugPrint(widget.textController.text),
                      },
                    ),
                  ],
                ),
        ],
      ),
    ),
  ],
),
```


## 表示の残り時間のバーのWidget
表示が完了または未完了のバーと表示中のバーをStackを使用して重ね合わせています。
それをLayoutBuilder使用してストーリーのコンテンツのデータの数だけレイアウトが収まるように実装しました。
ストーリーの表示の残り時間のバーは`AnimatedBuilder`を使用して実装しています。

表示の残り時間のバーの全体の実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/1d4524dcdd173bf4e92ba3c98ec068895993253d/lib/stories/screens/widgets/header.dart#L88)

```dart
class _AnimatedBar extends StatelessWidget {
  const _AnimatedBar({
    required this.animationController,
    required this.position,
    required this.currentIndex,
  });

  final AnimationController animationController;
  final int position;
  final int currentIndex;

  @override
  Widget build(BuildContext context) {
    return Flexible(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 2),
        child: LayoutBuilder(
          builder: (context, constraints) {
            return Stack(
              children: <Widget>[
                // 表示完了または未完了のバー
                _Bar(
                  width: double.infinity,
                  color: position < currentIndex
                      ? Colors.white
                      : Colors.white.withOpacity(0.5),
                ),
                // 表示中のバー
                position == currentIndex
                    ? AnimatedBuilder(
                        animation: animationController,
                        builder: (context, child) {
                          return _Bar(
                            width: constraints.maxWidth * animationController.value,
                          );
                        },
                      )
                    : const SizedBox.shrink(),
              ],
            );
          },
        ),
      ),
    );
  }
}
```

## バーの残り時間の読み込み処理
`animationController`にてストーリーのヘッダーに表示されているバーの残り時間を管理しています。
MediaTypeは`image`の場合は固定で3秒、`video`の場合は動画の長さ分を設定しています。


```dart
void _loadStory({
  required ContentData contentData,
  bool animateToPage = true,
}) {
  _animationController
    ..stop()
    ..reset();
  switch (contentData.media) {
    case MediaType.image:
      // １つ画像の表示する時間を設定
      _animationController.duration = const Duration(seconds: 3);
      _animationController.forward();
      break;
    case MediaType.video:
      _videoController?.dispose();
      _videoController = VideoPlayerController.network(contentData.url)
        ..initialize().then((_) {
          setState(() {});
          if (_videoController != null &&
              _videoController!.value.isInitialized) {
            // ビデオの長さ分の時間を設定
            _animationController.duration = _videoController!.value.duration;
            _videoController!.play();
            _animationController.forward();
          }
        });
      break;
  }
  if (animateToPage) {
    _pageController.animateToPage(
      _currentIndex,
      duration: const Duration(milliseconds: 1),
      curve: Curves.easeInOut,
    );
  }
}
```


## ストーリーのページング表示
ページングを実装するために、`PageView.builder`を使用しています。
ストーリー表示中の画面をタップした時の挙動は別で実装を行うために、`physics: const NeverScrollableScrollPhysics()`にしています。
また、ヘッダー部分はメッセージ入力欄と同様にストーリーの表示中に画面を長押しをした時にを非表示にするために`AnimatedOpacity`を使用しています。

```dart
Stack(
  children: <Widget>[
    PageView.builder(
      controller: _pageController,
      physics: const NeverScrollableScrollPhysics(),
      itemCount: widget.storyData.contentData.length,
      itemBuilder: (context, i) {
        final contentData = widget.storyData.contentData[i];
        return Content(
          contentData: contentData,
          focusNode: _focusNode,
          textController: _textController,
          videoController: _videoController,
          isLongPress: isLongPress,
        );
      },
    ),

    // ヘッダー部分(時間のバーとサムネ、ユーザ名)
    AnimatedOpacity(
      opacity: isLongPress ? 0.0 : 1.0,
      duration: const Duration(milliseconds: 500),
      child: Header(
        animationController: _animationController,
        currentIndex: _currentIndex,
        storyData: widget.storyData,
      ),
    ),
  ],
)
```


※ Headerの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/1d4524dcdd173bf4e92ba3c98ec068895993253d/lib/stories/screens/widgets/header.dart#L5)

## ストーリー表示中の画面をタップした挙動の実装
ストーリーを表示している時に画面の右側をタップしたら次の画像が表示されたり、長押し時の場合には動画が停止をしたりします。

[ストーリーのページング表示で実装した`PageView`に`GestureDetector`をWrapしてタップしたイベント毎に処理を追加していきます。

今回は`onTapUp`と`onLongPressStart`と`onLongPressEnd`を使用していきます。
それぞれのタップイベントの役割は下記の通りです。

- onTapUp
  - 画面の左側の1/3をタップした場合に1つ前の画像を表示
  - 画面の左側の2/3をタップした場合は1つ次の画像を表示
  - キーボードが表示されている場合はキーボードのフォーカスを外す
- onLongPressStart
  - 長押し時の場合にはアニメーションや動画の再生を停止する(キーボードが表示されいない場合のみ)

- onLongPressEnd
  - 長押し終了の場合にはアニメーションや動画の再生を再開する(キーボードが表示されいない場合のみ)

```dart
GestureDetector(
  // 画面タップ時の処理
  onTapUp: (details) {
    // キーボードが表示されている場合はキーボードのフォーカスを外すのみ
    if (_focusNode.hasFocus) {
      FocusScope.of(context).unfocus();
    } else {
      _onTapUp(
        details: details,
        contentData: contentData,
        textController: _textController,
        isLastStory: widget.isLastStory,
      );
    }
  },

  // 長押し時の場合にはアニメーションや動画の再生を停止する
  onLongPressStart: (details) {
    // キーボードが表示されている場合は処理を行わない
    if (_focusNode.hasFocus) {
      return;
    }

    _animationController.stop();
    if (_videoController != null &&
        _videoController!.value.isPlaying) {
      _videoController!.pause();
    }

    setState(() {
      isLongPress = true;
    });
  },

  // 長押し終了の場合にはアニメーションや動画の再生を再開する
  onLongPressEnd: (details) {
    // キーボードが表示されている場合は処理を行わない
    if (_focusNode.hasFocus) {
      return;
    }
    _animationController.forward();
    if (_videoController != null &&
        !_videoController!.value.isPlaying) {
      _videoController!.play();
    }

    setState(() {
      isLongPress = false;
    });
  },
  child: PageView.builder(
    controller: _pageController,
    physics: const NeverScrollableScrollPhysics(),
    itemCount: widget.storyData.contentData.length,
    itemBuilder: (context, i) {
      final contentData = widget.storyData.contentData[i];
      return Content(
        contentData: contentData,
        focusNode: _focusNode,
        textController: _textController,
        videoController: _videoController,
        isLongPress: isLongPress,
      );
    },
  ),
)
```

※ _onTapUpの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/f9dc661295489a7d545a6fa1a3c1ba87f6c5eaac/lib/stories/screens/widgets/story.dart#L210)


## ユーザ毎のストーリーのページング表示
ストーリーをい表示中に次のユーザのストーリーへは`PageView`を使用して右スワイプすることで実装可能です。

isScrollableが`true`の場合はスワイプ可能、`false`の場合はスワイプできないようにしています。
`false`になる場合はストーリーのメッセージ入力欄へのフォーカスしている時です。

```dart
physics: isScrollable ? null : const NeverScrollableScrollPhysics()
```


```dart
PageView.builder(
  controller: _pageController,
  physics: isScrollable ? null : const NeverScrollableScrollPhysics(),
  itemCount: stories.length,
  itemBuilder: (context, i) {
    final story = stories[i];
    return Story(
      pagesControl: _pageController,
      storyData: story,
      isLastStory: i + 1 == stories.length,
      updateScrollable: (value) {
        setState(() {
          isScrollable = value;
        });
      },
    );
  },
)
```

# まとめ
インスタのストーリーのようなUIを実装する方法を説明しました。
ライブラリーを使用せずに実装することで、インスタのストーリーの挙動を把握できたのでとても勉強になりした。

今回のソースコードはこちらのGitHubにて公開してます。

https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/stories/screens/stories_screen.dart


# おわりに
最後までお読みいただきありがとうございます。
間違っている点などあればコメントにて指摘いただけますと幸いです。


アプリ設計や実装でお困りの際はお気楽にお問合せください。

https://neverjp.com/contact/