Главное
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 использует animated-индикатор с поддержкой spring-анимации и адаптивного цвета на основе wallpaper. 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 автоматически управляет индикатором обновления и блокирует повторные запуски до завершения текущей загрузки — это стандартный recommended подход для новых проектов.
Используйте флаг 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 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также