Este artículo examina un componente personalizado de "pull-to-refresh" que encontré en un proyecto reciente. El efecto visual es el siguiente:
(Imagen omitida intencionalmente)
Dividiré el análisis en dos publicaciones: la primera cubre la arquitectura del control, y la segunda los detalles de la animación. El código fuente está disponible en el siguiente enlace:
1. Estructura de archivos
El control consta de los siguientes archivos: GMPullToAction, CircleProgressView y GMActivityView. El archivo GMPullToAction contiene dos clases: GMPullToRefresh y UIScrollView (GMPullToAction). CircleProgressView y GMActivityView son clases independientes encargadas de las animaciones.
En este conjunto, GMPullToRefresh y la categoría UIScrollView (GMPullToAction) forman el esqueleto del control, mientras que las otras dos clases gestionan los efectos visuales.
2. Integración básica
El control se define como una categoría de UIScrollView, por lo que debe usarse sobre una instancia de UIScrollView o sus subclases. Se requieren tres métodos. Supongamos que el controlador tiene una propiedad scrollView:
- (void)viewDidLoad {
[super viewDidLoad];
__weak typeof(self) weakSelf = self;
[self.scrollView addPullToRefreshWithActionHandler:^{
// Código a ejecutar al hacer pull-to-refresh
[weakSelf loadData];
}];
}
Además, es necesario implementar el delegado de UIScrollView:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (scrollView == self.scrollView) {
[scrollView didScroll];
}
}
Finalmente, al completar la carga de datos, se debe detener la animación:
[self.scrollView.pullToRefreshView stopAnimating];
Utilizaremos estos tres puntos de entrada para desglosar el funcionamiento interno.
3. Método addPullToRefreshWithActionHandler:
Este método está definido en la categoría UIScrollView (GMPullToAction). Su implementación:
- (void)addPullToRefreshWithActionHandler:(void (^)(void))actionHandler {
GMPullToRefresh *refreshControl = [[GMPullToRefresh alloc] initWithScrollView:self];
refreshControl.actionHandler = actionHandler;
self.pullToRefreshView = refreshControl;
}
Se crea una instancia de GMPullToRefresh usando initWithScrollView:, se asigna el bloque actionHandler y se almacena en una propiedad asociada.
Como las categorías no permiten propiedades almacenadas, se utiliza la asociación de objetos del runtime:
@interface UIScrollView (GMPullToAction)
@property (nonatomic, strong) GMPullToRefresh *pullToRefreshView;
@end
#import <objc/runtime.h>
static char kPullToRefreshKey;
@implementation UIScrollView (GMPullToAction)
@dynamic pullToRefreshView;
- (void)setPullToRefreshView:(GMPullToRefresh *)view {
[self willChangeValueForKey:@"pullToRefreshView"];
objc_setAssociatedObject(self, &kPullToRefreshKey, view, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[self didChangeValueForKey:@"pullToRefreshView"];
}
- (GMPullToRefresh *)pullToRefreshView {
return objc_getAssociatedObject(self, &kPullToRefreshKey);
}
@end
3.1 Inicialización de GMPullToRefresh
El método initWithScrollView: configura el marco, la etiqueta y las vistas de animación (se omiten los detalles de animación por ahora):
- (id)initWithScrollView:(UIScrollView *)scrollView {
CGFloat height = 64.0; // Altura fija definida como constante
self = [super initWithFrame:CGRectZero];
self.scrollView = scrollView;
self.frame = CGRectMake(0, -height, scrollView.bounds.size.width, height);
[scrollView addSubview:self];
// Etiqueta de texto
self.titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(CGRectGetMidX(self.bounds) - 37.5, height * 0.5 - 10, 75, 20)];
self.titleLabel.font = [UIFont boldSystemFontOfSize:14];
self.titleLabel.backgroundColor = [UIColor clearColor];
self.titleLabel.textColor = [UIColor darkGrayColor];
[self addSubview:self.titleLabel];
// Aquí se agregan las vistas de animación (se omiten)
// ...
self.state = GMPullToRefreshStateHidden;
return self;
}
El estado se define mediante una enumeración:
typedef NS_ENUM(NSUInteger, GMPullToRefreshState) {
GMPullToRefreshStateHidden = 1,
GMPullToRefreshStateVisible,
GMPullToRefreshStateTriggered,
GMPullToRefreshStateLoading
};
4. Método delegado scrollViewDidScroll:
En la implementación del delegado, se llama a didScroll de la categoría:
- (void)didScroll {
CGPoint offset = self.contentOffset;
if (self.pullToRefreshView) {
[self.pullToRefreshView scrollViewDidScroll:offset];
}
}
El método scrollViewDidScroll: de GMPullToRefresh actualiza el estado según la posición:
- (void)scrollViewDidScroll:(CGPoint)contentOffset {
if (self.state == GMPullToRefreshStateLoading) return;
CGFloat threshold = self.frame.origin.y; // Valor negativo, ej. -64
if (self.state == GMPullToRefreshStateTriggered && !self.scrollView.isDragging) {
self.state = GMPullToRefreshStateLoading;
} else if (contentOffset.y < 0 && contentOffset.y > threshold && self.scrollView.isDragging && self.state != GMPullToRefreshStateLoading) {
self.state = GMPullToRefreshStateVisible;
} else if (contentOffset.y <= threshold && self.scrollView.isDragging && self.state == GMPullToRefreshStateVisible) {
self.state = GMPullToRefreshStateTriggered;
} else if (contentOffset.y >= 0 && self.state != GMPullToRefreshStateHidden) {
self.state = GMPullToRefreshStateHidden;
}
// Aquí se manejan las animaciones durante el desplazamiento
}
4.1 Setter de estado
El setter setState: actualiza la interfaz y ejecuta el bloque de carga:
- (void)setState:(GMPullToRefreshState)newState {
if (_state == newState) return;
_state = newState;
switch (newState) {
case GMPullToRefreshStateHidden:
self.titleLabel.text = @"";
[self adjustScrollViewContentInsetTop:0];
break;
case GMPullToRefreshStateVisible:
self.titleLabel.text = NSLocalizedString(@"Desliza hacia abajo para refrescar...", nil);
break;
case GMPullToRefreshStateTriggered:
self.titleLabel.text = NSLocalizedString(@"Suelta para refrescar...", nil);
break;
case GMPullToRefreshStateLoading:
self.titleLabel.text = NSLocalizedString(@"Cargando...", nil);
[self adjustScrollViewContentInsetTop:self.frame.size.height];
if (self.actionHandler) {
self.actionHandler();
}
break;
}
// Animaciones adicionales (se omiten)
}
El método adjustScrollViewContentInsetTop: modifica el contentInset con animación:
- (void)adjustScrollViewContentInsetTop:(CGFloat)top {
UIEdgeInsets insets = self.scrollView.contentInset;
insets.top = top;
[UIView animateWithDuration:0.3
delay:0
options:UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionBeginFromCurrentState
animations:^{
self.scrollView.contentInset = insets;
} completion:nil];
}
5. Detener la animación
Cuando los datos se han cargado, se llama a stopAnimating para regresar al estado oculto:
- (void)stopAnimating {
self.state = GMPullToRefreshStateHidden;
}
6. Resumen del esqueleto
Hasta aquí hemos analizado la arquitectura básica del control de pull-to-refresh. En la siguiente entrega exploraremos las animaciones implementadas por CircleProgressView y GMActivityView.