重要なポイント
Pull-to-Refreshは、ユーザーがリストやスクロール可能な領域を下に引っ張って(pull down)コンテンツを更新するユーザーインターフェースパターンです。視覚的には、ジェスチャーには画面上部に表示される読み込みインジケーター(スピナー)が伴い、データを受信すると消えます。このパターンはiPhone向けアプリTweetie(2008)によって普及し、その後Apple(iOS 6 — UIRefreshControl)とGoogle(Android Support Library — SwipeRefreshLayout)によって標準化されました。
技術的な観点から見ると、Pull-to-Refreshはパンニング(指の変位の追跡)としきい値に達したときのトリガーの組み合わせです。ユーザーは抵抗(抵抗性オーバースクロール)に打ち勝ってリストを下に引っ張り、しきい値(iOSで約80px、Androidで約64dp)を超えると、インジケーターのアニメーションと非同期読み込みが開始されます。ユーザーがしきい値に達する前に指を離すと、リストは更新されずに元の位置に戻ります。
Material Designガイドラインによると、Pull-to-Refreshはナビゲーションやタブ切り替えに使用すべきではありません — その唯一の目的はデータの更新です。IT Sectrでは、データの鮮度がユーザーエクスペリエンスにとって重要なニュースフィード、注文フィード、チャットでPull-to-Refreshを使用しています。
UIRefreshControlは、Pull-to-Refresh用の標準iOSコントロールで、iOS 6から利用可能です。UIRefreshControlは、refreshControlプロパティ(iOS 10+)を介してUITableViewControllerに追加されるか、以前のバージョンではテーブルのサブビューとして追加されます。カスタマイズ可能な色(tintColor)、title属性、ラベル付きの属性付き文字列(例:「更新中...」)を備えた組み込みスピナーが含まれています。
UIRefreshControlはtarget-actionメカニズムを介して動作します:ジェスチャーがアクティブになると、指定されたメソッド(例:refresh(_:))が呼び出されます。メソッド内で、非同期データ読み込みが実行されます。完了後、endRefreshing()が呼び出され、アニメーションでインジケーターを非表示にします。UIRefreshControlは自動的にジェスチャーの感度を管理します — テーブルが最上部にある場合(contentOffset.y <= 0)にのみトリガーされます。
tintColorプロパティはスピナーの色を設定します。attributedTitleを使用すると、完了後に「2分前に更新」などのテキストを表示できます。iOS 10以降、UIRefreshControlはUIActivityIndicatorViewまたは永続的なカスタムビューを介したカスタムアニメーションをサポートしています。IT Sectrでは、ブランドに合わせてtintColorを設定し、attributedTitleを介して最終更新時刻を表示しています — これにより、ユーザーのデータに対する信頼が高まります。
SwipeRefreshLayoutは、Android Support Library(androidx.swiperefreshlayout)のViewGroupで、スクロール可能なコンテンツ(RecyclerView、NestedScrollView、ListView)をラップし、Pull-to-Refresh機能を追加します。UIRefreshControl(コントロールであり、コンテナではない)とは異なり、SwipeRefreshLayoutは子のタッチイベントをインターセプトし、しきい値を超えると更新インジケーターをトリガーするコンテナです。
SwipeRefreshLayoutは、setColorSchemeColors()を介した色のカスタマイズが可能な円形のMaterial Design進行状況インジケーターを使用します。setOnRefreshListenerメソッドはonRefresh()コールバックを設定し、その中で非同期読み込みが実行されます。完了後、setRefreshing(false)が呼び出されてインジケーターを非表示にします。重要:setRefreshing(true)はonRefresh()を再度呼び出します — そのため、プログラムで更新を開始するには、フラグまたはpostメソッドを使用してください。
setProgressBackgroundColorSchemeResourceプロパティはインジケーターの背景を変更します。setSize(SwipeRefreshLayout.LARGE)はスピナーのサイズを設定します。XMLレイアウトでは、SwipeRefreshLayoutがRecyclerViewをラップします:swipe_refresh_layout → recycler_view。Google I/O 2024によると、SwipeRefreshLayoutはコンテンツフィードを持つAndroidアプリの85%で使用されています。IT Sectrでは、非同期で読み込まれるリストを持つすべての画面をSwipeRefreshLayoutでラップしています — これにより、すべてのAndroidバージョンで一貫したUXが提供されます。
Android 12(Material You)以降、Googleはmaterial-1.6.0+ライブラリ(Composeの場合はandroidx.compose.material3.pulltorefresh)の新しいMaterial Pull-to-Refreshの使用を推奨しています。新しいAPIは、スプリングアニメーションのサポートと壁紙に基づくアダプティブカラーを備えたアニメーションインジケーターを使用します。SwipeRefreshLayoutはAndroid 12未満のバージョンでも互換性があります。
Pull-to-Refreshは実装が簡単なパターンですが、UXを低下させるよくある間違いがいくつかあります。それらと防止方法を見てみましょう。
IT Sectrでは、テストサーバーのログで重複リクエストを発見した後、すべてのプロジェクトにisRefreshingチェックを追加しました — 指の速いユーザーが最大3回連続で更新をトリガーしていたことが判明しました。
カスタムスピナー色と属性付きタイトルでUITableViewControllerにPull-to-Refreshを追加します。データ読み込み後、インジケーターが非表示になります。
import UIKit
class FeedTableViewController: UITableViewController {
private var items: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.refreshControl = UIRefreshControl()
refreshControl?.tintColor = .systemBlue
refreshControl?.attributedTitle = NSAttributedString(
string: "引っ張って更新"
)
refreshControl?.addTarget(
self,
action: #selector(refreshData),
for: .valueChanged
)
}
@objc private func refreshData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.items = FeedService().fetchLatest()
self.tableView.reloadData()
self.refreshControl?.endRefreshing()
}
}
}
tableView.refreshControlプロパティ(iOS 10+)がUIRefreshControlを設定します。.valueChangedイベントを持つaddTargetは、ジェスチャーがアクティブになったときに発生します。endRefreshing()は必須です — これがないとインジケーターが無限に回転し続けます。非同期読み込みはDispatchQueue.main.asyncAfterでシミュレートされています — 実際のプロジェクトでは、URLSessionまたはasync/awaitを使用します。
カスタムインジケーター色でSwipeRefreshLayoutにRecyclerViewをラップします。onRefreshが読み込みを開始し、完了後にインジケーターを非表示にします。
class FeedFragment : Fragment() {
private var _binding: FragmentFeedBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentFeedBinding.inflate(inflater, container, false)
binding.swipeRefreshLayout.setColorSchemeColors(
resources.getColor(R.color.brand_blue, null),
resources.getColor(R.color.brand_green, null)
)
binding.swipeRefreshLayout.setOnRefreshListener {
loadData()
}
return binding.root
}
private fun loadData() {
viewModelScope.launch {
try {
val result = repository.getLatestFeed()
adapter.submitList(result)
} finally {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
setColorSchemeColorsは回転するMaterial Designインジケーターの色を設定します。isRefreshing = falseはfinallyブロックで呼び出され、読み込みエラー時でもインジケーターを非表示にします。ViewModelScope.launchはフラグメントのライフサイクル内でコルーチンを実行します — フラグメントが破棄されると、コルーチンは自動的にキャンセルされ、メモリリークを防ぎます。
最新のSwiftUIは.refreshable修飾子を提供し、ListまたはScrollViewに自動的にPull-to-Refreshを追加します。
import SwiftUI
struct FeedView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.refreshable {
items = await FeedService().fetchLatestAsync()
}
}
}
.refreshable修飾子は、Pull-to-Refreshで実行されるasyncクロージャを受け取ります。SwiftUIは自動的に更新インジケーターを表示・非表示にし、状態の競合を管理し(現在の更新が完了するまで新しい更新を開始しない)、プラットフォームに合わせてアニメーションを適応させます。iOS 15+では、これがSwiftUIでPull-to-Refreshを実装する推奨方法です。
よくある質問
はい、SwiftUIはListまたはScrollView用に.refreshable修飾子を提供しており、iOS 15から利用可能です。クロージャ内で非同期データ読み込みコードが実行されます。SwiftUIは自動的に更新インジケーターを管理し、現在の読み込みが完了するまで繰り返しのトリガーをブロックします — これは新しいプロジェクトの標準的な推奨アプローチです。
isRefreshingフラグを使用します:読み込み開始時にtrue、完了後にfalseに設定します。iOSでは、endRefreshing()が呼び出されるまでUIRefreshControlが自動的に繰り返し呼び出しをブロックします。Androidでは、onRefresh()の開始時にSwipeRefreshLayout.isRefreshingをチェックします:trueの場合はreturnします。これにより、1ジェスチャーあたり1リクエストが保証されます。
UIRefreshControlとSwipeRefreshLayoutは、リストが最上部にある場合(contentOffset == 0)にのみトリガーされます。アーキテクチャが競合を排除しています:リストが1pxでもスクロールされている間は、Pull-to-Refreshジェスチャーはアクティブになりません。競合が発生した場合は、AndroidのnestedScrollingEnabled、またはタッチをインターセプトするカスタムGestureRecognizerの有無を確認してください。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。