Puntos clave
Pull-to-Refresh es un patrón de interfaz de usuario donde el usuario arrastra (pull down) una lista o área desplazable hacia abajo para actualizar el contenido. Visualmente, el gesto va acompañado de un indicador de carga (spinner) que aparece en la parte superior de la pantalla y desaparece tras recibir los datos. El patrón fue popularizado por la aplicación Tweetie para iPhone (2008) y posteriormente estandarizado por Apple (iOS 6 — UIRefreshControl) y Google (Android Support Library — SwipeRefreshLayout).
Desde un punto de vista técnico, Pull-to-Refresh es una combinación de paneo (seguimiento del desplazamiento del dedo) y un disparador al alcanzar un umbral. El usuario arrastra la lista hacia abajo, superando la resistencia (overscroll resistivo), y tras exceder el umbral (~80px en iOS, ~64dp en Android), se inicia la animación del indicador y la carga asíncrona. Si el usuario suelta el dedo antes del umbral — la lista vuelve a su posición original sin actualizarse.
Según las Guías de Material Design, Pull-to-Refresh no debe usarse para navegación o cambio de pestañas — su único propósito es la actualización de datos. En IT Sectr, usamos Pull-to-Refresh en feeds de noticias, listas de pedidos y chats donde la frescura de los datos es crítica para la experiencia del usuario.
UIRefreshControl es un control estándar de iOS para Pull-to-Refresh, disponible desde iOS 6. UIRefreshControl se añade a UITableViewController mediante la propiedad refreshControl (iOS 10+) o como subview de la tabla en versiones anteriores. Incluye un spinner integrado con color personalizable (tintColor), atributo title y una cadena atribuida con un título (por ejemplo, “Actualizando...”).
UIRefreshControl funciona mediante un mecanismo target-action: cuando se activa el gesto, se llama al método especificado (por ejemplo, refresh(_:)). Dentro del método se realiza la carga asíncrona de datos. Tras finalizar, se llama a endRefreshing(), que oculta el indicador con animación. UIRefreshControl gestiona automáticamente la sensibilidad del gesto — solo se activa cuando la tabla está en la posición superior (contentOffset.y <= 0).
La propiedad tintColor establece el color del spinner. Los atributos title permiten mostrar texto como “Actualizado hace 2 minutos” tras finalizar. Desde iOS 10, UIRefreshControl admite animaciones personalizadas mediante UIActivityIndicatorView o vistas personalizadas persistentes. En IT Sectr, configuramos tintColor según la marca y mostramos la hora de la última actualización mediante attributedTitle — esto aumenta la confianza del usuario en los datos.
SwipeRefreshLayout es un ViewGroup de Android Support Library (androidx.swiperefreshlayout) que envuelve contenido desplazable (RecyclerView, NestedScrollView, ListView) y añade funcionalidad Pull-to-Refresh. A diferencia de UIRefreshControl (que es un control, no un contenedor), SwipeRefreshLayout es un contenedor que intercepta los eventos táctiles del hijo y activa el indicador de actualización al superar el umbral.
SwipeRefreshLayout utiliza un indicador de progreso circular Material Design con personalización de color mediante setColorSchemeColors(). El método setOnRefreshListener establece el callback onRefresh(), en el que se realiza la carga asíncrona. Tras finalizar, se llama a setRefreshing(false) para ocultar el indicador. Importante: setRefreshing(true) vuelve a llamar a onRefresh() — por lo tanto, para la actualización programática, use una bandera o un método post.
La propiedad setProgressBackgroundColorSchemeResource cambia el fondo del indicador. setSize(SwipeRefreshLayout.LARGE) — tamaño del spinner. En el diseño XML, SwipeRefreshLayout envuelve RecyclerView: swipe_refresh_layout → recycler_view. Según Google I/O 2024, SwipeRefreshLayout se usa en el 85% de las aplicaciones Android con feeds de contenido. En IT Sectr, envolvemos todas las pantallas con listas cargadas asíncronamente en SwipeRefreshLayout — esto proporciona una UX consistente en todas las versiones de Android.
A partir de Android 12 (Material You), Google recomienda usar el nuevo Material Pull-to-Refresh de la librería material-1.6.0+ (androidx.compose.material3.pulltorefresh para Compose). La nueva API utiliza un indicador animado con soporte de animación spring y color adaptativo basado en el fondo de pantalla. SwipeRefreshLayout sigue siendo compatible para versiones inferiores a Android 12.
Pull-to-Refresh es un patrón simple de implementar, pero contiene varios errores comunes que degradan la UX. Revisémoslos y las formas de evitarlos.
En IT Sectr, añadimos una comprobación isRefreshing en cada proyecto después de descubrir solicitudes duplicadas en los registros del servidor de prueba — resultó que los usuarios con dedos rápidos activaban la actualización hasta 3 veces seguidas.
Añade Pull-to-Refresh a UITableViewController con color de spinner personalizado y attributed title. Después de cargar los datos, el indicador se oculta.
import UIKit
class FeedTableViewController: UITableViewController {
private var items: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.refreshControl = UIRefreshControl()
refreshControl?.tintColor = .systemBlue
refreshControl?.attributedTitle = NSAttributedString(
string: "Pull down to refresh"
)
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()
}
}
}
La propiedad tableView.refreshControl (iOS 10+) establece UIRefreshControl. addTarget con el evento .valueChanged se activa cuando se activa el gesto. endRefreshing() es obligatorio — sin él, el indicador girará indefinidamente. La carga asíncrona se simula con DispatchQueue.main.asyncAfter — en un proyecto real, use URLSession o async/await.
Envuelve RecyclerView en SwipeRefreshLayout con colores de indicador personalizados. onRefresh inicia la carga y oculta el indicador tras completar.
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 establece los colores del indicador giratorio Material Design. isRefreshing = false es obligatorio en finally para ocultar el indicador incluso en caso de error de carga. ViewModelScope.launch ejecuta una corrutina dentro del ciclo de vida del fragmento — cuando el fragmento se destruye, la corrutina se cancela automáticamente, evitando fugas de memoria.
SwiftUI moderno proporciona el modificador .refreshable, que añade automáticamente Pull-to-Refresh a List o 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()
}
}
}
El modificador .refreshable acepta un async-closure que se ejecuta al hacer Pull-to-Refresh. SwiftUI muestra y oculta automáticamente el indicador de actualización, gestiona las condiciones de carrera (no reinicia la carga hasta que la actual termine) y adapta la animación a la plataforma. Para iOS 15+, esta es la forma preferida de implementar Pull-to-Refresh en SwiftUI.
Preguntas frecuentes
Sí, SwiftUI proporciona el modificador .refreshable para List o ScrollView, disponible desde iOS 15. Dentro del closure se ejecuta código de carga asíncrona. SwiftUI gestiona automáticamente el indicador de actualización y bloquea los lanzamientos repetidos hasta que se complete la carga actual — este es el enfoque recomendado estándar para nuevos proyectos.
Use el indicador isRefreshing: establézcalo en true cuando comience la carga y en false después de completar. En iOS, UIRefreshControl bloquea automáticamente las llamadas repetidas hasta que se llama a endRefreshing(). En Android, verifique SwipeRefreshLayout.isRefreshing al inicio de onRefresh(): si es true — return. Esto garantiza una solicitud por gesto.
UIRefreshControl y SwipeRefreshLayout se activan solo cuando la lista está en la posición superior (contentOffset == 0). La arquitectura elimina el conflicto: mientras la lista esté desplazada aunque sea 1px, el gesto Pull-to-Refresh no se activa. Si se produce un conflicto — verifique nestedScrollingEnabled en Android o la presencia de GestureRecognizers personalizados que intercepten los toques.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también