【Swift】Apple Watch で Pull To Refresh を実装する
初めに
今回は Apple Watch で Pull To Refresh 機能を実装していきます。
そもそも Pull To Refresh とは、名前の通り「引っ張って更新する」機能です。コンテンツがリスト形式で表示されていて、ユーザーがもっとコンテンツを読み込みたいと感じた時に画面を下方向へ引っ張って離すとコンテンツの取得が行われるといった挙動になります。
X や Instagram、YouTube のアプリなど広く採用されています。
この挙動を Apple Watch でもできるようにしたいと思います。
記事の対象者
- Swift, SwiftUI 学習者
- Apple Watch の実装をしたい方
目的
今回の目的は上記の通り、 Apple Watch で Pull To Refresh を実現することです。
iOS 側のアプリでは List
に refreshable
を付与して、その中にリフレッシュ時に実行したい処理を記述すれば簡単に Pull To Refresh が実装できます。
しかし、 watchOS で同じように refreshable
を用いた実装を行うと Pull To Refresh ができないことがわかりました。
refreshable の公式ドキュメント をみてみると「watchOS 8.0+」と書いてあり、使用できそうなのですが、実際に動かしてみるとリフレッシュできません。
今回は自前でビューを作成してこの課題を解消していきます。
実装
実装は以下の手順で進めていきます。
- iOS と watchOS の挙動の違い
- スクロール量を取得する実装
- Pull To Refresh の実装
1. iOS と watchOS の挙動の違い
まずは手元で refreshable
の挙動を確認してみます。
SwiftUI で Pull To Refresh の挙動を実現する際は以下のように List
に対して refreshable
を付与してその中にリフレッシュ時に実行する処理を記述して実装することが多いかと思います。
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 )
watchOS Simulator ( Apple Watch Ultra 2 (49mm) - watchOS 11.0 )
2. スクロール量を取得する実装
Pull To Refresh の処理は、スクロール可能な画面でユーザーが大きく下側にスワイプして離すという動作を行なった際に発火します。したがって、自前で実装する場合は画面がどの程度スクロールされたかを把握する必要があります。
以下では画面のスクロール量を取得する実装を行なっています。
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
}
上記のコードを実行すると以下の動画のように、リストのスクロールに応じて画面右上のスクロール量が変化していることがわかります。
コードを詳しくみていきます。
以下の部分では、 item
を表示している LazyVStack
に対して、 background
に GeometryReader
を渡しています。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章で述べたスクロール量の取得方法を用いて実装していきます。
コードは以下の通りです。
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]
}
これで実行すると以下の動画のように、ランダムで実行時間が決まる処理をリフレッシュで呼び出せていることがわかります。
以上です。
まとめ
最後まで読んでいただいてありがとうございました。
今回は watchOS の Pull To Refresh 処理を自前で作成する実装を行いました。
本来であれば、特別な理由がない限りすでに提供されているメソッドで実装するのが良いと思いますが、今回は自分で見つけられなかったため、自前で実装しました。
これから実装される方がいれば参考になれば嬉しいです。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion