🔄

【Swift】Apple Watch で Pull To Refresh を実装する

2024/12/02に公開

初めに

今回は Apple Watch で Pull To Refresh 機能を実装していきます。
そもそも Pull To Refresh とは、名前の通り「引っ張って更新する」機能です。コンテンツがリスト形式で表示されていて、ユーザーがもっとコンテンツを読み込みたいと感じた時に画面を下方向へ引っ張って離すとコンテンツの取得が行われるといった挙動になります。
X や Instagram、YouTube のアプリなど広く採用されています。

この挙動を Apple Watch でもできるようにしたいと思います。

記事の対象者

  • Swift, SwiftUI 学習者
  • Apple Watch の実装をしたい方

目的

今回の目的は上記の通り、 Apple Watch で Pull To Refresh を実現することです。
iOS 側のアプリでは Listrefreshable を付与して、その中にリフレッシュ時に実行したい処理を記述すれば簡単に Pull To Refresh が実装できます。
しかし、 watchOS で同じように refreshable を用いた実装を行うと Pull To Refresh ができないことがわかりました。

refreshable の公式ドキュメント をみてみると「watchOS 8.0+」と書いてあり、使用できそうなのですが、実際に動かしてみるとリフレッシュできません。
今回は自前でビューを作成してこの課題を解消していきます。

実装

実装は以下の手順で進めていきます。

  1. iOS と watchOS の挙動の違い
  2. スクロール量を取得する実装
  3. Pull To Refresh の実装

1. iOS と watchOS の挙動の違い

まずは手元で refreshable の挙動を確認してみます。
SwiftUI で Pull To Refresh の挙動を実現する際は以下のように List に対して refreshable を付与してその中にリフレッシュ時に実行する処理を記述して実装することが多いかと思います。

PullToRefreshSampleView.swift
import SwiftUI

struct PullToRefreshSampleView: View {
    @State private var items: [Item] = [
        Item(title: "Item 1"),
        Item(title: "Item 2"),
        Item(title: "Item 3")
    ]
    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.title)
            }
        }
        .refreshable {
            onRefresh()
        }
    }
    
    func onRefresh() {
        print("onRefresh")
        let newItem = Item(title: "Item \(self.items.count + 1)")
        self.items.append(newItem)
    }
}

このコードを iOS と watchOS の両方で実行してみると以下のようになります。
watchOS の場合のみリフレッシュ時の処理が実行されていないことがわかります。
同じように watchOS のリフレッシュ処理が実行されないと書かれている記事もあったため、今回はこの問題を解消していきます。watchOS の動画が縦長になっていますが、watchOS Simulator の画面を録画したものです ...

iOS Simulator ( iPhone 16 Pro - iOS 18.0 )

https://youtube.com/shorts/UDY7d4aMVZM

watchOS Simulator ( Apple Watch Ultra 2 (49mm) - watchOS 11.0 )

https://youtube.com/shorts/aX0p0IbXEVc

2. スクロール量を取得する実装

Pull To Refresh の処理は、スクロール可能な画面でユーザーが大きく下側にスワイプして離すという動作を行なった際に発火します。したがって、自前で実装する場合は画面がどの程度スクロールされたかを把握する必要があります。

以下では画面のスクロール量を取得する実装を行なっています。

ScrollValueView.swift
import SwiftUI

struct ScrollValueView: View {
    private var items: [Item] {
        (1...10).map { Item(title: "Item \($0)") }
    }
    @State private var scrollOffset: CGFloat = 0

    var body: some View {
        ZStack(alignment: .topTrailing) {
            ScrollView {
                LazyVStack {
                    ForEach(items) { item in
                        Text(item.title)
                            .padding()
                    }
                }
                .background(
                    GeometryReader { proxy in
                        Color.clear
                            .onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, newValue in
                                scrollOffset = newValue
                            }
                    }
                )
            }
            .coordinateSpace(.named("ScrollView"))
            
            Text(String(format: "%.2f", scrollOffset))
                .padding()
                .background(Color.black.opacity(0.5))
                .foregroundColor(.white)
                .cornerRadius(8)
                .padding([.trailing], 10)
        }
    }
}

struct Item: Identifiable {
    let id = UUID()
    let title: String
}

上記のコードを実行すると以下の動画のように、リストのスクロールに応じて画面右上のスクロール量が変化していることがわかります。

https://youtube.com/shorts/Iq4un2eg52s

コードを詳しくみていきます。

以下の部分では、 item を表示している LazyVStack に対して、 backgroundGeometryReader を渡しています。GeometryReader から渡される proxy には LazyVStack の親である ScrollView のサイズや LazyVStack がどの位置にあるかなどが含まれています。

background で表示させたいビューは特にないため、Color.clear を割り当て、 proxy.frame(in: .named("ScrollView")).minY の変化を読み取って、 scrollOffset に代入しています。

proxy.frame(in: ) では受け取った proxy のうち、 ScrollView と名前がつけられているビューに特に注目し、.minY でそのビューのY座標の最小値、つまりそのビューの一番上の位置を取得しています。

LazyVStack {
    ForEach(items) { item in
        Text(item.title)
            .padding()
    }
}
.background(
    GeometryReader { proxy in
        Color.clear
            .onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, newValue in
                scrollOffset = newValue
            }
    }
)

以下では ScrollView に対して ScrollView という名前を付与しています。
これで先ほどの proxy.frame(in: .named("ScrollView")).minY が名前を読み取ることができるようになります。
coordinateSpace(.named) では名前の通り、座標空間に対して名前をつけることができます。
今回は ScrollView に対して名前をつけて他の部分から参照できるようにしています。

ScrollView {
  // 省略
}
.coordinateSpace(.named("ScrollView"))

これで画面のスクロール量を取得する実装ができました。
最後の章で Pull To Refresh の実装を行いたいと思います。

3. Pull To Refresh の実装

最後に本題の Pull To Refresh の実装を行います。
2章で述べたスクロール量の取得方法を用いて実装していきます。

コードは以下の通りです。

PullToRefreshListView.swift
import SwiftUI

struct PullToRefreshListView: View {
    @State private var items: [Item] = [
        Item(title: "Item 1"),
        Item(title: "Item 2"),
        Item(title: "Item 3")
    ]
    @State private var isRefreshing = false
    
    var body: some View {
        ScrollView {
            LazyVStack {
                if isRefreshing {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle())
                        .padding()
                }
                
                ForEach(items) { item in
                    Text(item.title)
                        .padding()
                }
            }
            .background {
                GeometryReader { proxy in
                    Color.clear.onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, newValue in
                        let offset = newValue
                        let threshold: CGFloat = 50
                        
                        if offset > threshold && !isRefreshing {
                            isRefreshing = true
                            Task {
                                await onRefresh()
                                isRefreshing = false
                            }
                        }
                    }
                }
            }
        }
        .coordinateSpace(name: "ScrollView")
    }
    
    func onRefresh() async {
        // 非同期でデータを取得する処理
        do {
            let newItems = try await fetchDataFromNetwork()
            withAnimation {
                self.items.append(contentsOf: newItems)
            }
        } catch {
            print("Error fetching data: \(error)")
        }
    }
    
    func fetchDataFromNetwork() async throws -> [Item] {
        // ランダムな時間(1〜5秒)を待機
        let randomDelay = UInt64.random(in: 1_000_000_000...5_000_000_000)
        debugPrint("\(randomDelay / 1_000_000_000)秒間待機 ...")
        try await Task.sleep(nanoseconds: randomDelay)
        
        let newItem = Item(title: "Item \(self.items.count + 1)")
        return [newItem]
    }
}

struct Item: Identifiable {
    let id = UUID()
    let title: String
}

それぞれ詳しくみていきます。

以下ではリフレッシュ中の場合に ProgressView を表示させるようにしています。
ProgressView で実装されていることが多いイメージですが、「データ取得中 ...」のようなテキストでも良いかもしれません。

if isRefreshing {
    ProgressView()
        .progressViewStyle(CircularProgressViewStyle())
        .padding()
}

2章で実装した内容とほぼ同じですが、threshold としてリフレッシュが発火するために必要なスクロール量を定義しています。この値が大きければリフレッシュが発火するためにより大きくスクロールする必要があります。この閾値は使いやすいようにカスタマイズする必要があるかと思います。
また、 isRefreshing が true の時、つまりリフレッシュ中に再度リフレッシュが発火することがないようにしています。

.background {
    GeometryReader { proxy in
        Color.clear.onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, newValue in
            let offset = newValue
            let threshold: CGFloat = 50
            
            if offset > threshold && !isRefreshing {
                isRefreshing = true
                Task {
                    await onRefresh()
                    isRefreshing = false
                }
            }
        }
    }
}

2章の実装と同様に ScrollView に対して名前を付与して GeometryReader から読み取れるようにしています。

ScrollView {
  // 省略
}
.coordinateSpace(name: "ScrollView")

以下ではリフレッシュが発火した際に実行する処理を記述しています。
もちろんこの辺りはアプリによりますが、今回は 1 ~ 5 秒間ランダムでスリープする処理をデモとして実装しています。

func onRefresh() async {
    // 非同期でデータを取得する処理
    do {
        let newItems = try await fetchDataFromNetwork()
        withAnimation {
            self.items.append(contentsOf: newItems)
        }
    } catch {
        print("Error fetching data: \(error)")
    }
}

func fetchDataFromNetwork() async throws -> [Item] {
    // ランダムな時間(1〜5秒)を待機
    let randomDelay = UInt64.random(in: 1_000_000_000...5_000_000_000)
    debugPrint("\(randomDelay / 1_000_000_000)秒間待機 ...")
    try await Task.sleep(nanoseconds: randomDelay)
    
    let newItem = Item(title: "Item \(self.items.count + 1)")
    return [newItem]
}

これで実行すると以下の動画のように、ランダムで実行時間が決まる処理をリフレッシュで呼び出せていることがわかります。

https://youtube.com/shorts/zka3Trrjfew

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は watchOS の Pull To Refresh 処理を自前で作成する実装を行いました。
本来であれば、特別な理由がない限りすでに提供されているメソッドで実装するのが良いと思いますが、今回は自分で見つけられなかったため、自前で実装しました。

これから実装される方がいれば参考になれば嬉しいです。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://developer.apple.com/documentation/swiftui/view/refreshable(action:)

https://note.com/taatn0te/n/n724c6c0deabc

Discussion