Головне
Pull-to-Refresh — патерн користувацького інтерфейсу, при якому користувач тягне (pull down) список або область, що прокручується, вниз для оновлення вмісту. Візуально жест супроводжується індикатором завантаження (spinner), який з'являється у верхній частині екрана та зникає після отримання даних. Патерн був популяризований додатком Tweetie для iPhone (2008) і згодом стандартизований Apple (iOS 6 — UIRefreshControl) та Google (Android Support Library — SwipeRefreshLayout).
З технічної точки зору Pull-to-Refresh — це комбінація панорамування (відстеження зміщення пальця) та тригера при досягненні порогу. Користувач тягне список вниз, долаючи опір (резистивний overscroll), і після перевищення порогу (~80px на iOS, ~64dp на Android) запускається анімація індикатора та асинхронне завантаження. Якщо користувач відпускає палець до порогу — список повертається у вихідне положення без оновлення.
Згідно з Material Design Guidelines, Pull-to-Refresh не повинен використовуватися для навігації або перемикання вкладок — його єдине призначення оновлення даних. В IT Sectr ми застосовуємо Pull-to-Refresh у новинних стрічках, стрічках замовлень та чатах, де свіжість даних критична для користувацького досвіду.
UIRefreshControl — стандартний елемент керування iOS для Pull-to-Refresh, доступний з iOS 6. UIRefreshControl додається до UITableViewController через властивість refreshControl (iOS 10+) або як subview таблиці в більш ранніх версіях. Він містить вбудований спінер з настроюваним кольором (tintColor), title-атрибутом та атрибутованим рядком з підписом (наприклад, «Оновлення...»).
UIRefreshControl працює через target-action механізм: при активації жесту викликається вказаний метод (наприклад, refresh(_:)). Всередині методу виконується асинхронне завантаження даних. Після завершення викликається endRefreshing(), який приховує індикатор з анімацією. UIRefreshControl автоматично керує чутливістю жесту — він спрацьовує тільки при верхньому положенні таблиці (contentOffset.y <= 0).
Властивість tintColor задає колір спінера. Атрибути title дозволяють показувати текст «Оновлено 2 хвилини тому» після завершення. Починаючи з iOS 10, UIRefreshControl підтримує кастомні анімації через UIActivityIndicatorView або персистентні кастомні в'ю. В IT Sectr ми налаштовуємо tintColor під бренд і показуємо час останнього оновлення через attributedTitle — це підвищує довіру користувачів до даних.
SwipeRefreshLayout — ViewGroup з Android Support Library (androidx.swiperefreshlayout), що обгортає прокручуваний контент (RecyclerView, NestedScrollView, ListView) та додає функціональність Pull-to-Refresh. На відміну від UIRefreshControl (який є контролом, а не контейнером), SwipeRefreshLayout — це контейнер, який перехоплює touch-події дитини та запускає індикатор оновлення при перевищенні порогу.
SwipeRefreshLayout використовує круговий прогрес-індикатор Material Design з налаштуванням кольору через setColorSchemeColors(). Метод 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 використовується в 85% Android-додатків зі стрічками контенту. В IT Sectr ми обгортаємо в SwipeRefreshLayout всі екрани з асинхронно завантажуваними списками — це дає єдиний UX на всіх версіях Android.
Починаючи з Android 12 (Material You), Google рекомендує використовувати новий Material Pull-to-Refresh з бібліотеки material-1.6.0+ (androidx.compose.material3.pulltorefresh для Compose). Новий API використовує анімований індикатор з підтримкою spring-анімації та адаптивного кольору на основі шпалер. SwipeRefreshLayout залишається сумісним для версій нижче Android 12.
Pull-to-Refresh — простий у реалізації патерн, але містить кілька типових помилок, що знижують UX. Розглянемо їх та способи запобігання.
В IT Sectr ми додали перевірку isRefreshing в кожному проекті після того, як виявили дублюючі запити в логах тестового сервера — виявилося, користувачі з швидкими пальцями запускали оновлення до 3 разів поспіль.
Додає Pull-to-Refresh в UITableViewController з кастомним кольором спінера та attributed title. Після завантаження даних індикатор приховується.
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. addTarget з подією .valueChanged спрацьовує при активації жесту. endRefreshing() обов'язковий — без нього індикатор буде крутитися нескінченно. Асинхронне завантаження симулюється DispatchQueue.main.asyncAfter — в реальному проекті тут URLSession або async/await.
Обгортає RecyclerView в SwipeRefreshLayout з кастомними кольорами індикатора. 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, який автоматично додає Pull-to-Refresh до List або ScrollView.
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 приймає async-closure, який виконується при Pull-to-Refresh. SwiftUI автоматично показує та приховує індикатор оновлення, керує гонкою стану (не запускає повторне завантаження до завершення поточного) та адаптує анімацію під платформу. Для iOS 15+ це preferred спосіб реалізації Pull-to-Refresh в SwiftUI.
Поширені запитання
Так, SwiftUI надає модифікатор .refreshable для List або ScrollView, доступний з iOS 15. Всередині closure виконується async-код завантаження даних. SwiftUI автоматично керує індикатором оновлення та блокує повторні запуски до завершення поточного завантаження — це стандартний рекомендований підхід для нових проектів.
Використовуйте флаг isRefreshing: встановіть true при старті завантаження і false після завершення. В iOS UIRefreshControl автоматично блокує повторний виклик, поки не викликаний endRefreshing(). В Android перевіряйте SwipeRefreshLayout.isRefreshing на початку onRefresh(): якщо true — return. Це гарантує один запит за один жест.
UIRefreshControl та SwipeRefreshLayout спрацьовують тільки при верхньому положенні списку (contentOffset == 0). Архітектура виключає конфлікт: поки список прокручений хоч на 1px, жест Pull-to-Refresh не активується. Якщо конфлікт виник — перевірте nestedScrollingEnabled в Android або наявність кастомних GestureRecognizer, що перехоплюють дотики.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також