🐨

SwiftでonChange内の処理がうまく実行されない時の解決方法

2025/02/17に公開

発生した事象

keyboardを表示したらtodo一覧の一番下まで自動でスクロールさせたいです。
キーボードが開閉するタイミングを検知して、onChangeイベント内でscrollTo関数を使って処理を実行させているのに何故かスクロールしてくれません。


コードはこんな感じ

TodoScreenView.swift
//
//  TodoScreenVIEW.swift
//  ai_chat_app
//
//  Created by 池内隆人 on 2025/02/09.
//

import SwiftUI
import SwiftData

struct TodoScreenView: View {
    @Environment(\.modelContext) private var context
    
    @Query private var todoItemList: [TodoItem]
    @State private var titleTextInput = ""
    @State private var isKeyboardOpen = false

    var body: some View {
        ZStack {
            VStack {
                ScrollViewReader { proxy in
                    List {
                        ForEach(todoItemList) { todoItem in TodoItemView(todoItem: todoItem)}.onDelete(perform: {
                            indexList in for index in indexList {
                                delete(todoItem: todoItemList[index])
                            }
                        })
                        Color.clear.id(999)
                    }.padding().onChange(of: isKeyboardOpen) {
                        if(isKeyboardOpen) {
                            withAnimation(.smooth(duration: 10)) {
                                proxy.scrollTo(999, anchor: .top)
                            }
                        }
                    }

                    HStack {
                        TextField("Title", text: $titleTextInput, axis: .vertical).padding(16).font(.title3).overlay(
                            RoundedRectangle(cornerRadius: 8)
                                .stroke(Color.gray.opacity(0.5), lineWidth: 1)
                        ).onSubmit {
                            add(title: titleTextInput)
                        }
                        Button("追加") {
                            add(title: titleTextInput)
                        }
                    }.padding(20)
                }
            }
        }.ignoresSafeArea(.container, edges: .bottom).onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in isKeyboardOpen = true}.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in isKeyboardOpen = false}
    }
    
    private func add(title: String) {
        let data = TodoItem(id: UUID(), title: title, isDone: false)
        context.insert(data)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
    
    private func delete(todoItem: TodoItem) {
        context.delete(todoItem)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
}

struct TodoItemView: View {
    var todoItem: TodoItem
    
    var body: some View {
        HStack {
            Circle().fill(todoItem.isDone ? Color.red : Color.green).frame(width: 30, height: 30)
            VStack(alignment: .leading) {
                Text(todoItem.title).font(.title).padding(20)
            }
        }
    }
}

原因

onChangeイベントが発生後にすぐscrollTo関数を実行しているのが原因です。
UI(今回だとキーボードが開かれたり閉じたりしたこと)の反映後ScrollTo関数がほぼ同時に行われていることでうまく機能していないようです。

解決方法

メインスレッドでのランタイムを待たせる(処理を少し遅延させる)ことで解決します。

TodoScreenView.swift
.onChange(of: isKeyboardOpen) { newValue in
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        withAnimation {
            proxy.scrollTo(999, anchor: .top)
        }
    }
}

DispatchQueue.main.asyncAfterとは

https://developer.apple.com/documentation/dispatch/dispatchqueue/asyncafter(deadline:execute:)

見慣れないメソッドが出てきたので調べました。
メインスレッド上で一定の遅延時間を置いてから処理を実行してくれるメソッドとのこと。
UIの更新やユーザーの操作系は基本メインスレッドで実行されるので、「UIが更新されたら~」とか「特定の操作が完了したら~」の後に後続処理をさせたい場合に使用します。

つまりこのメソッドを使うことで、ただTimerメソッドみたいに秒数を待つのではなくて、UIの更新が終わった後みたいなイベントの後に続けて処理をしたい時に有用です。

例)

  • いいねボタンを押した0.5秒後にアニメーションでハートを一瞬大きくしたい
  • 投稿内容がUIに完全に反映された後に「投稿しました!」というトーストを出したい

検証

うまくできました!
DispatchQueue.main.asyncAfterメソッドは多用も良くないと思うのでアプリの要件に合わせて使い方はしっかり考えた方が良さそうです。

ヘッドウォータース

Discussion