【Flutter】HTMLを描画したWidgetを折りたたみできるようにする

この記事は以下で投稿した内容と同じになります

https://zenn.dev/harx/articles/7527a938483023#はじめに

---

## はじめに

長い文章などを折り畳んで表示し、文章の下に配置した「詳しく見る」や「もっと見る」ボタンなどで全文を展開するUIはよく見ると思います。
そんなUIを構築する有名なパッケージはreadmoreだと思います。

https://pub.dev/packages/readmore

しかし、こちらは主に`TextWidget`限定の機能です。
今回ご紹介するのは
**HTMLを描画した`Widget`においてある特定の長さになった場合に折りたたむ**
という実装についてです。

### 記事の対象者

- HTMLを描画した`Widget`を折りたたみ、展開するUIを構築したい方
- HTMLを描画する方法を知りたい方

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.29.0, on macOS 15.3.1 24D70darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (AndroidSDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.97.2)
```

### サンプルプロジェクト

https://www.youtube.com/watch?v=4lWfF2q6jmQ

### ソースコード

https://github.com/HaruhikoMotokawa/custom_show_more_sample/tree/main

## 要件

- 一部のデータがHTMLで送られてきており、Widgetで描画したい
- HTMLのデータ量は変動するため、ある程度の長さを基準として折りたたみ表示したい
- データ内に動画の埋め込みがあった場合はタップしたら再生できるようにしたい
- データ何にURLリンクがあればタップしたらコピーできるようにしたい（今回表示はしない）

## HTMLの表示の実装

### パッケージの選定

今回はパッケージ、flutter_widget_from_htmlを使用します。
あまり複雑な内容(タグ？CSS?)は表示できないようなので、対応している内容については公式をご覧ください。

https://pub.dev/packages/flutter_widget_from_html

有名なパッケージとしてはflutter_htmlがあるのですが、３年前の更新を最後にメンテナンスがされていません。
最初はこちらを使おうと思っていたのですが、パッケージ内の依存関係で一部gradleのバージョンが古いものがあり、ビルドができませんでした。
色々調べて使えるようにしようと思ったのですが今回は断念しました。

https://pub.dev/packages/flutter_html

### 実装

専用のWidgetの第一引数に表示したいHTMLを渡します。
今回の要件としてURLのリンクがあればタップしてコピーする、というものがあるので`onTapUrl`に実行する処理を書きます。
今回はコピーですが、例えばブラウザで開くことも可能です。
埋め込まれた動画については特に何も設定しなくても表示、再生ができます。

念のためですが、`onTapUrl`は戻り値として`bool`を要求してきますので、忘れずに書きましょう。

```dart
child: HtmlWidget(
  onePieceHtml,
  onTapUrl: (url) async {
    // urlをコピーする
    await Clipboard.setData(ClipboardData(text: url));
    if (!context.mounted) return false;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('URLをコピーしました: $url')),
    );
    return true;
  },
),

```
## 折りたたみ、展開するWidgetの実装

### やりたいこと
- 表示するWidgetの高さを取得する
- 基準以上の高さの場合は折りたたみ表示にする
  - もっと見るボタンで全文を展開表示できる
  - 閉じるボタンで元の折りたたみ表示に戻れる
    - 閉じた時にはスクロールの位置をその要素の上とデバイスの上を合わせる
  - 複数の要素を展開、または閉じていた場合の状態を保持するようにする
- 基準以下の高さの場合は何もしない
  - 折りたたむボタンも表示しない

### 参考にさせてもらった実装

https://zenn.dev/motu2119/articles/expandable-see-more-20240506

今回の実装の大元はこの方の記事をほぼほぼ取り入れて作りました。
今回は画像データを直接取り込んで表示する用件はなかったので、その点を省いた実装となっています。
### 実装

```dart:lib/expandable_show_more.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

/// ラップしたWidgetを折りたたみ表示するWidget
///
/// このWidgetは、ラップしたWidgetの高さが指定した高さを超えた場合に
/// 折りたたみ表示を行います。
///
/// INFO: 画像関連のWidgetには未対応です。
class ExpandableShowMore extends HookWidget {
  const ExpandableShowMore({
    required this.child,
    this.scrollController,
    this.collapsedHeight = 300.0,
    super.key,
  });

  /// 折りたたむ高さの基準
  ///
  /// デフォルトは300.0
  final double collapsedHeight;

  /// ラップするWidget
  final Widget child;

  /// スクロール位置を制御するためのコントローラ
  ///
  /// 呼び出し側のスクロール位置を制御するために使用
  final ScrollController? scrollController;

  @override
  Widget build(BuildContext context) {
    /// この要素内の状態が画面外に出ても破棄されないように保持する
    useAutomaticKeepAlive();

    /// 要素に一意のキーを設定
    final contentKey = useMemoized(GlobalKey.new);

    /// 折りたたみが必要かどうか
    final shouldCollapse = useState(true);

    /// 展開している状態かどうか
    final isExpanded = useState(false);

    useEffect(
      () {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          final box =
              contentKey.currentContext?.findRenderObject() as RenderBox?;
          // 要素の高さが折りたたみ基準の高さより小さい場合は折りたたみ不要
          if (box != null && box.size.height < collapsedHeight) {
            shouldCollapse.value = false;
          }
        });
        return null;
      },
      [],
    );

    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 画面を閉じた場合に親のAnimatedSizeの高さが正確に反映されない問題を解決するため
        // にIntrinsicHeightを使用
        IntrinsicHeight(
          child: AnimatedSize(
            duration: const Duration(milliseconds: 300),
            alignment: AlignmentDirectional.bottomCenter,
            curve: Curves.easeInOut,
            child: SizedBox(
              // 折りたたみが必要 かつ 折りたたみ状態の場合は高さを制限
              height: (shouldCollapse.value && isExpanded.value == false)
                  ? collapsedHeight
                  : null,
              child: Stack(
                children: [
                  // 主にHtmlWidgetなどのColumn要素を内包するWidgetの
                  // オーバーフローを制御するためにSingleChildScrollViewでラップ
                  SingleChildScrollView(
                    // このキーを指定されている要素の高さを取得するために使用
                    key: contentKey,
                    physics: const NeverScrollableScrollPhysics(),
                    // ここにラップ対象のWidgetが入る
                    child: child,
                  ),
                  // 折りたたみが必要 かつ 折りたたみ状態の場合はグラデーションを表示
                  if (shouldCollapse.value && isExpanded.value == false)
                    const _GradientMask(),
                ],
              ),
            ),
          ),
        ),
        // 折りたたみが必要の場合はボタンを表示
        if (shouldCollapse.value)
          Align(
            alignment: Alignment.centerRight,
            child: TextButton(
              onPressed: () => onButtonPressed(contentKey, isExpanded),
              child: Text(isExpanded.value ? '閉じる' : 'もっと見る'),
            ),
          ),
      ],
    );
  }
}
```

<details><summary>グラデーションをかけている _GradientMask()</summary>

```dart
/// 折りたたまれている場合に要素のタブにグラデーションをかける
class _GradientMask extends StatelessWidget {
  const _GradientMask();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return Positioned(
      left: 0,
      right: 0,
      bottom: 0,
      child: Container(
        height: 50,
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              colorScheme.surface.withValues(alpha: 0),
              colorScheme.surface.withValues(alpha: 0.5),
              colorScheme.surface.withValues(alpha: 1),
            ],
          ),
        ),
      ),
    );
  }
}
```

</details>
実装内容はコメントに書いてある通りですが、以下に実装の要点を記します。

#### キーを使って要所のサイズを取得する
```dart
final contentKey = useMemoized(GlobalKey.new);
```
`GlobalKey` を生成して `SingleChildScrollView` に割り当てています。
これによって、`contentKey.currentContext` を使ってその要素の 描画後のサイズ を取得できるようになります。

#### サイズをチェックして折りたたみが必要かどうかを決定
```dart
useEffect(() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final box = contentKey.currentContext?.findRenderObject() as RenderBox?;
    if (box != null && box.size.height < collapsedHeight) {
      shouldCollapse.value = false;
    }
  });
  return null;
}, []);
```
`addPostFrameCallback` を使って、レイアウト（描画）が終わった後のタイミングで実行。
`RenderBox` を使って実際の高さを取得し、`collapsedHeight`（デフォルト300px）より小さければ、折りたたみ不要と判定しています。

### 折りたたみ または 展開する時の処理
```dart
extension on ExpandableShowMore {
  /// 'もっと見る' または '閉じる' ボタンの処理
  void onButtonPressed(
    GlobalKey contentKey,
    ValueNotifier<bool> isExpanded,
  ) {
    isExpanded.value = !isExpanded.value;

    // コントローラーを渡されている　かつ　折りたたみ状態になった場合
    if (scrollController != null && isExpanded.value == false) {
      // キーを指定されている要素（ExpandableShowMoreの子）のレンダーオブジェクトを取得
      final objectBox =
          contentKey.currentContext?.findRenderObject() as RenderBox?;

      if (objectBox != null) {
        // `objectBox` の現在のスクリーン座標（`Offset.zero` は左上の座標）を取得
        // `ancestor` にはスクロールビューのコンテキストを指定し、相対位置を計算する
        final offset = objectBox.localToGlobal(
          Offset.zero,
          ancestor: scrollController!.position.context.storageContext
              .findRenderObject(),
        );
        // `offset.dy` を加算して、スクロール位置を調整
        // `scrollController!.offset` は現在のスクロール位置
        // `offset.dy` は `objectBox` の現在のスクリーン上のY座標（= スクロール位置から見た要素の位置）
        final targetScrollOffset = scrollController!.offset + offset.dy;
        // 目標のスクロール位置へアニメーションで移動
        scrollController!.animateTo(
          targetScrollOffset,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeInOut,
        );
      }
    }
  }
}
```

折りたたみ状態に戻したときに、ユーザーの視点がズレないよう、スクロール位置を自動で調整しています。
`localToGlobal` を使って要素のスクリーン座標を取得し、スムーズにアニメーション移動させています。

## 終わりに
今回は、HTMLを描画するWidgetに対して「もっと見る」「閉じる」のUIを追加し、一定の高さを超えた場合に折りたたみ・展開を切り替えられる仕組みを紹介しました。

Flutterではテキストベースの折りたたみ表示には便利なパッケージがいくつかありますが、HTMLのような複雑なWidgetを扱う場合は、サイズを測定して自前で制御する必要があります。
本記事で紹介した `ExpandableShowMore` のようなアプローチを使うことで、より柔軟なUIを実現できます。

HTMLの中にリンクや動画などの要素が含まれていても対応できるような拡張も可能ですので、ぜひアプリに合わせてカスタマイズしてみてください。

この記事が同じような要件で悩んでいる方の参考になれば幸いです！