3
3

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 1 year has passed since last update.

[SwiftUI]ScrollView でキーボードを被らないようにScrollViewを一番下まで移動する方法

Last updated at Posted at 2023-06-17

対象読者

ScrollViewで要素を追加したら一番下まで移動して欲しい
ScrollViewの内容がキーボードを開くと隠れてしまうのでなんとかしたい

といった方対象です。

問題背景

LINEみたいなChatアプリを実装することを考えます。

スクロール可能な実装として、ScrollViewでTextを並べます。

そして、下部にTextFieldを追加し、入力した内容がどんどん下に追加追加されていくようにしたいです。

しかしながら、そのままではあまり想像通りには動きません。

愚直に実装すると以下のようになります。


import SwiftUI

struct ScrollViewBottom: View {
    @State private var messages: [String] = Array(0...50).map(String.init)
    @State private var message: String = ""
    @State private var keyboardIsShowing: Bool = false

    var body: some View {
        VStack {
            ScrollView {
                ScrollViewReader { proxy in
                    ForEach(messages.indices, id: \.self) { index in
                        Text(messages[index])
                            .id(index)
                    }
                }
            }

            HStack {
                TextField("Enter message", text: $message)
                    .textFieldStyle(.roundedBorder)

                Button(action: {
                    if !message.isEmpty {
                        messages.append(message)
                        message = ""
                    }
                }) {
                    Text("Send")
                }
            }
            .padding()
        }
    }
}

実際に動かしたものが以下になります。

1.gif
3.gif

現在の問題点としては、二つの点が挙げられます。

  1. 新しいTextが追加されてもキーボードの下に隠れてしまう。
  2. キーボードを開くと、ScrollViewが隠れてしまう。

一つずつ対処していきましょう。

1. 新しいTextが追加されてもキーボードの下に隠れてしまう。

この対処法としては

messageが新しく追加されたら、ScrollViewを一番下まで移動するようにします。

ScrollView単体では、スクロール位置を直接制御する簡単な方法はありません。そのためScrollViewReaderを使用することでこれを実現できます。ScrollViewReaderはScrollView内のビューにIDを割り当て、そのIDを使って特定のビューにスクロールすることを可能にします。

その適応をしたコードが以下になります。



import SwiftUI

struct ScrollViewBottom: View {
    @State private var messages: [String] = Array(0...50).map(String.init)
    @State private var message: String = ""
    @State private var keyboardIsShowing: Bool = false

    var body: some View {
        VStack {
            ScrollView {
                ScrollViewReader { proxy in
                    ForEach(messages.indices, id: \.self) { index in
                        Text(messages[index])
                            .id(index)
                    }.onChange(of: messages.count) { _ in
                        let lastIndex = messages.count - 1
                        if lastIndex >= 0 {
                            withAnimation {
                                proxy.scrollTo(lastIndex, anchor: .bottom)
                            }
                        }
                    }
                }
            }

            HStack {
                TextField("Enter message", text: $message)
                    .textFieldStyle(.roundedBorder)

                Button(action: {
                    if !message.isEmpty {
                        messages.append(message)
                        message = ""
                    }
                }) {
                    Text("Send")
                }
            }
            .padding()
        }
    }
}

上記のコードでは、TextFieldとButtonを使用して新たなメッセージを追加できるようになっています。新たなメッセージが追加されると、ScrollViewが最下部の新たなメッセージにスクロールします。この動作はonChangeを使用して実現しており、messages.countが変わるたびに最新のメッセージのIDまでスクロールします。

2.gif

追加するたびに、ScrollViewも自動的に一番下に移動してくれているのが確認できますね。4.gif
4.gif

2. 新しいTextが追加されてもキーボードの下に隠れてしまう。

この対処法としては

先ほどと同様、キーボードが開かれたら、ScrollViewの一番下まで移動するようにします。

キーボードの表示/非表示を検出するために、NotificationCenterを利用します。キーボードが表示されるとき(UIResponder.keyboardWillShowNotification)と非表示になるとき(UIResponder.keyboardWillHideNotification)の両方の通知を監視し、適切に対応します。

全体の実装は以下のとおりです。


import SwiftUI
import Combine

struct ScrollViewBottom: View {
    @State private var messages: [String] = Array(0...50).map(String.init)
    @State private var message: String = ""
    @State private var keyboardIsShowing: Bool = false

    var body: some View {
        VStack {
            ScrollView {
                ScrollViewReader { proxy in
                    ForEach(messages.indices, id: \.self) { index in
                        Text(messages[index])
                            .id(index)
                    }
                    .onChange(of: messages.count) { _ in
                        let lastIndex = messages.count - 1
                        if lastIndex >= 0 {
                            withAnimation {
                                proxy.scrollTo(lastIndex, anchor: .bottom)
                            }
                        }
                    }
                    .onChange(of: keyboardIsShowing) { value in
                        let lastIndex = messages.count - 1
                        if lastIndex >= 0 && value {
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                withAnimation {
                                    proxy.scrollTo(lastIndex, anchor: .bottom)
                                }
                            }
                        }
                    }
                }
            }
            HStack {
                TextField("Enter message", text: $message)
                    .textFieldStyle(.roundedBorder)

                Button(action: {
                    if !message.isEmpty {
                        messages.append(message)
                        message = ""
                    }
                }) {
                    Text("Send")
                }
            }
            .padding()
        }
        .onReceive(Publishers.Merge(
            NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification).map { _ in true },
            NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification).map { _ in false }
        )) { self.keyboardIsShowing = $0 }
    }
}


このコードでは、キーボードの表示状態をkeyboardIsShowingで管理します。キーボードが表示されたら(keyboardWillShowNotification)、または非表示になったら(keyboardWillHideNotification)、keyboardIsShowing**の値が更新されます。その値が変更されたときには、onChangeが発動して最新のメッセージまでスクロールします。

しかしながらそのまま実装すると、キーボードが開くのと一番下まで移動するのが同時に行われてしまい、適切に動作しません。それを回避するために、DispatchQueueを用いて、すこし処理を遅らせます。

4.gif

キーボードによって隠れる問題も解決できました。

まとめ

何気なく使っている動作が、かなり基本のものに手を加えられたことを再認識させられる内容でした。

いいね、ブックマーク、フォローしていただけると勉強の励みになりますので是非お願いします。😉

追伸 --
Twitterで日々の学習風景を投稿してます。

@Ren_yello

3
3
1

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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?