対象読者
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()
}
}
}
実際に動かしたものが以下になります。
現在の問題点としては、二つの点が挙げられます。
- 新しいTextが追加されてもキーボードの下に隠れてしまう。
- キーボードを開くと、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までスクロールします。
追加するたびに、ScrollViewも自動的に一番下に移動してくれているのが確認できますね。
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を用いて、すこし処理を遅らせます。
キーボードによって隠れる問題も解決できました。
まとめ
何気なく使っている動作が、かなり基本のものに手を加えられたことを再認識させられる内容でした。
いいね、ブックマーク、フォローしていただけると勉強の励みになりますので是非お願いします。😉
追伸 --
Twitterで日々の学習風景を投稿してます。