✍️

【Flutter】1画面にUIを収め、快適なテキスト入力を実現したい!

2025/02/09に公開

スマホアプリのランディングページなどで、
1画面にUIを収めたいときありませんか?

以前こちらの記事にて、下記の対応をしました。

  • Expandedを使用して各要素の高さを「画面内の比率」で指定する(flex: 1 / flex: 2など)
  • textScaleFactorを使用してテキストの大きさを固定する(状況に応じてFittedBoxなどで個別に対応)

上記の対応は1例でExpandedを使用すればある程度柔軟に対応は可能かと思いつつ、
今回の新たな要望としては、
1画面内にUIを収める、かつテキスト入力を実現したい!
ということです。

具体的には、下記の動画の挙動を実装したいです。

「テキスト入力するだけ?」と思いますが、
より良いUI/UXを実現するには、
キーボード表示時にテキストフィールドまでスクロールする、
かつキーボードを表示したままボタンが押せるようにする
のが良いかなと思います。

…ということは1画面に要素を収め、かつスクロールできるようにする必要があります。

要件

下記のような要件があったとします。

  • 1画面に要素は収める
    • 端末によって画像サイズは可変で問題ない
  • テキストフィールドをタップしたら画面下部のテキストの表示を変える
    • 画面下部の「別のおじさんを見る」と「テキスト入力でおじさんを〜」の表示を入れ替える必要がある
  • キーボードの上に画面下部のテキスト・ボタン・テキストフィールドを配置させる

上記を満たすにはどうしたら良いでしょうか?

結論

下記の順番で動作させてみるといける気がしています。

①スマホ画面の大きさを取得する
②スマホ画面の大きさに応じたUIを一度描画する
③描画した画像の高さを取得する
Expandedだった画像の高さを固定値にする( Expanded のままだと画面下部のWidgetが可変なので画像の大きさが変化してしまいます)
⑤スマホ画面の大きさで描画する制約を除く(キーボードが開かれた際、下部のWidgetが切り替わり余分な余白が生まれるのを避けるためです)
⑥キーボード表示で最下部までスクロールされるようにする。

具体的なコードは下記の感じです!
(長いので載せているコードは一部省略しています)

class OzisanPage extends HookWidget {
  const OzisanPage({super.key});

  /// 画像の高さを取得するためのキー
  static final imageKey = GlobalKey();

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final safeAreaPadding = MediaQuery.paddingOf(context);

    // ① デバイスの高さを取得
    final deviceHeight = MediaQuery.sizeOf(context).height;

    final textEditingController = useTextEditingController();
    final focusNode = useFocusNode();
    final scrollController = useScrollController();
    final isFocused = useState(false);
    final imageHeight = useState(0.0);

    // ③ 画像の高さを取得する
    useEffect(
      () {
        // 画像のレンダリング完了後、確実に高さを取得する
        SchedulerBinding.instance.addPostFrameCallback((_) {
          void measureHeight() {
            final renderBox =
                imageKey.currentContext?.findRenderObject() as RenderBox?;
            final height = renderBox?.size.height ?? 0.0;
            if (height > 0) {
              imageHeight.value = height;
            } else {
              // 高さが0の場合、次のフレームで再試行
              SchedulerBinding.instance.addPostFrameCallback((_) {
                measureHeight();
              });
            }
          }

          measureHeight();
        });
        return null;
      },
      const [],
    );

    // テキストフィールドにフォーカスが当たっている状態を管理する処理
    useEffect(
      () {
        void listener() {
          final hasFocus = focusNode.hasFocus;
          isFocused.value = hasFocus;
        }

        focusNode.addListener(listener);
        return () {
          focusNode.removeListener(listener);
        };
      },
      [focusNode],
    );

    return GestureDetector(
      onTap: () => primaryFocus?.unfocus(),
      child: Scaffold(
        body: SingleChildScrollView(
          controller: scrollController,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20),
            child: SizedBox(
              // ② スマホ画面の高さに応じたUIを描画する
              //
              // ⑤ スマホ画面の大きさの制約を除く(キーボードが開かれた際、下部のWidgetが切り替わり余分な余白が生まれるのを避けるためです)
              height: imageHeight.value == 0.0 ? deviceHeight : null,
              child: Column(
                children: [
                  SizedBox(height: safeAreaPadding.top),
                  ...
                  if (imageHeight.value == 0.0)
                    Expanded(
                      child: Image.asset(
                        'assets/ozisan.png',
                        key: imageKey,
                        fit: BoxFit.contain,
                      ),
                    )
                  else
                    // ④ 画像の高さを固定にする
                    Image.asset(
                      'assets/ozisan.png',
                      height: imageHeight.value,
                      fit: BoxFit.contain,
                    ),
                  ...
                  TextField(
                    focusNode: focusNode,
                    controller: textEditingController,
                    decoration: InputDecoration(
                      hintText: '褒め言葉を入力',
                    ),
                    // ⑥ キーボード表示で最下部までスクロールされるようにする。(下部のWidgetは可変なため、大きい値を使用しています)
                    scrollPadding: EdgeInsets.only(
                      bottom: deviceHeight,
                    ),
                  ),
                  const SizedBox(height: 32),
                  if (isFocused.value) ...[
                    Text('テキスト入力でおじさんを褒めてあげて!'),
                    SizedBox(height: 12),
                  ],
                  ElevatedButton(
                    onPressed: () {...},
                    child: const Text('おじさんを褒める'),
                  ),
                  if (!isFocused.value) ...[
                    SizedBox(height: 8),
                    TextButton(
                      onPressed: () {...},
                      child: const Text('別のおじさんを見る'),
                    ),
                  ],
                  SizedBox(
                    // セーフエリアのない端末の場合はUIの微調整をする
                    height: max(safeAreaPadding.bottom, 16),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

↓すべてのコードはこちらです。

https://github.com/oke331/ozisan_with_text_field/blob/main/lib/sign_up_page.dart#L1C1-L232C2

ただ、このやり方で正解かわからず、
もっと良いコードがある気もしております…。
下記の意見を歓迎しております🙇‍♂️

  • 「もっと良いやり方あるよ!」
  • 「こうすれば簡単にできるよ!」
  • 「このパターンだと、動作微妙かも!?」

試行錯誤の過程

一応、どういう経緯でこのコードになったかを載せておきます。

①② について

①スマホ画面の大きさを取得する
②スマホ画面の大きさに応じたUIを一度描画する(画像はExpandedで描画)

「画面全体に描画」+「キーボード上にボタンや入力UIが来るようにする」ということは、
SingleChildScrollView などのスクロール可能なWidgetを使用する必要があるのかなと思いました。

しかし、単純にSingleChildScrollView を使用すると、
1画面にピッタリ収まるUIを構築することはできません。

そのため、一度スマホの画面の大きさに応じたUIを描画し、
スクロール可能にしました。

具体的には、MediaQuery.sizeOf(context).heightでスマホ画面の高さを取得し、
SizedBoxheightを固定にしています。

今回は画像の大きさを可変にできるため、
画像のみExpandedにしています。

③④⑤について

③描画した画像の高さを取得する
④Expandedだった画像の高さを固定値にする( Expanded のままだと画面下部のWidgetが可変なので画像の大きさが変化してしまいます)
⑤スマホ画面の大きさの制約を除く(キーボードが開かれた際、下部のWidgetが切り替わり余分な余白が生まれるのを避けるためです)

ここまでの実装だと、テキストフィールドタップ時に画像が伸び縮みしてしまいます。
下部のテキストが変化するので、高さが変わるためかと思います。

そのため、画像の高さを取得できたら、
その高さを固定で描画する必要がありました。

画像の高さに関してはSchedulerBindingでフレームごとに画像が描画されたことをチェックしてから画像の高さをimageHeightに入れるようにしています。

※下記のようなコードでも問題ないと思います。サンプルのコードではアプリ起動後すぐに描画している関係か、WidgetsBinding.instance.addPostFrameCallbackをしたあとでも画像の高さが取得できないことがありSchedulerBindingをしています。

    useEffect(
      () {
        // 画像の読み込み完了を待つ
        final image = Image.asset('assets/ozisan.png').image;
        final imageStream = image.resolve(ImageConfiguration.empty);
        final listener = ImageStreamListener(
          (ImageInfo info, bool _) {
            WidgetsBinding.instance.addPostFrameCallback((_) {
              final renderBox =
                  imageKey.currentContext?.findRenderObject() as RenderBox?;
              // heightが0になることがある。。
              imageHeight.value = renderBox?.size.height ?? 0.0;
            });
          },
        );
        imageStream.addListener(listener);
        return () {
          imageStream.removeListener(listener);
        };
      },
      const [],
    );

そして、画像の高さが固定となり高さをdeviceHeightで縛る必要はなくなったので、
SizedBoxheightをnullにしています。
これをしないと、⑥をしたあとに下部のWidgetが切り替わり余分な余白が生まれてしまうことがありました。

⑥について

⑥最下部までスクロールされるようにする。(下部のWidgetは可変で高さが取得できないので、大きい値を使用している)

ここまでの実装だと、テキストフィールドへのスクロールはうまくいきますが、
その下に表示されているボタンはキーボードを閉じないとタップできません。

これでは不便なので、ボタンのところまで(最下部まで)スクロールしてあげる必要があります。

(補足で、下部のテキストがテキストフィールドタップ時に切り替わるため事前に高さを取得できないことや、長いテキストの場合折り返しも考慮する必要があり固定値にしたくないので、今回は固定値にしたくなく大きな値deviceHeightを渡しています。
下部のWidgetの値が取得できるのであれば、deviceHeightではなくWidgetの高さを指定してあげるのが適切かと思います)

  TextField(
    focusNode: focusNode,
    controller: textEditingController,
    decoration: InputDecoration(
      hintText: '褒め言葉を入力',
    ),
    // deviceHeightを渡すことでキーボード表示時に最下部まで表示される!
    scrollPadding: EdgeInsets.only(bottom: deviceHeight),
  ),

※ちなみにこの方法は執筆途中で気づきまして、
元々はWidgetsBindingObserverdidChangeMetricsでキーボードが開かれたことを読み取り、ScrollControllerで最下部までスクロールすることで解決していました。
その方法でも悪くないですが、こちらのほうがスッキリ書けました。

※追記 キーボード表示をしたまま画面遷移のケア

キーボードが表示されたままこの画面に遷移してしまうと画像が小さいまま表示される可能性があるので、下記のようなコードを足しても良さそうです!

    // 画像の高さを取得する処理
    useEffect(
      () {
        // 画像の読み込み完了を待つ
        final image = Assets.images.walkThrough.portfolio.image().image;
        final imageStream = image.resolve(ImageConfiguration.empty);

        final listener = ImageStreamListener(
          (ImageInfo info, bool _) {
            // 画像が読み込まれた後に高さを取得
            WidgetsBinding.instance.addPostFrameCallback((_) async {

              // 追加!
              final isKeyboardVisible =
                  MediaQuery.viewInsetsOf(context).bottom > 0;
              if (isKeyboardVisible) {
                primaryFocus?.unfocus();
                await waitForKeyboardToClose(context);
              }

              final renderBox =
                  imageKey.currentContext?.findRenderObject() as RenderBox?;
              imageHeight.value = renderBox?.size.height ?? 0.0;
            });
          },

...

  /// キーボードが完全に閉じたことを確認するメソッド
  Future<void> waitForKeyboardToClose(BuildContext context) async {
    // 最大待機時間
    final maxWaitTime = DateTime.now().add(const Duration(milliseconds: 1500));

    // キーボードが閉じるまで間隔をおいて確認
    while (context.mounted) {
      final currentBottomInset = MediaQuery.of(context).viewInsets.bottom;

      // キーボードが完全に閉じた場合またはタイムアウトした場合
      if (currentBottomInset == 0 || DateTime.now().isAfter(maxWaitTime)) {
        break;
      }

      // 50ms待機
      await Future.delayed(const Duration(milliseconds: 50));
    }
  }

さいごに

稀なパターンかもしれませんが、どなたかのお役にたてば幸いです…!

また、色々試行錯誤した結果このような実装になったのですが、
もっと簡単に実装できる方法があったのでは…と思っています😹

「こんな風にしたら簡単にできるよ!」などあればぜひ教えてくださいっ!

Discussion