Cómo hacer un pull to refresh en UITableView, por @j4n0

Autor: | Última modificación: 22 de noviembre de 2022 | Tiempo de Lectura: 4 minutos
Temas en este post:

Pull to refresh en UITableView

Pull to Refresh es un tipo de interacción que Loren Brichter patentó en su cliente de Twitter «Tweetie». Luego Twitter compró Tweetie, y supongo que ha licenciado la patente a Apple, porque iOS 6 incluye una implementación llamada UIRefreshControl. He aquí un ejemplo de su uso:

-(void) viewDidLoad {
    [super viewDidLoad];
    UIRefreshControl *refreshControl = [UIRefreshControl new];
    [refreshControl addTarget:self action:@selector(refresh:) forControlEvents:UIControlEventValueChanged];
    refreshControl.attributedTitle = [[NSMutableAttributedString alloc] initWithString:@"Pulsa para refrescar..."];
    self.refreshControl = refreshControl;
}

- (void)refresh:(UIRefreshControl *)sender {
    // ... código de refresco
    [sender endRefreshing];
}
Lo que vemos es una vista sobre la tabla, un spinner (UIActivityIndicatorView), y un icono animado. Cuando el usuario tira de la tabla, la vista de arriba se queda fija durante el refresco gracias a un tableView.contentInset.

Hay muchas implementaciones de este patrón. La de Apple utiliza un «droplet» dibujado en Quartz (imagino que unas 4 páginas al menos). Aquí voy a explicar como hacer una sencilla. El código completo está en GitHub.

PullView

Empiezo creando la vista que vemos al tirar. Crearé un UIView con NIB para ahorrar código.

Las dimensiones de la figura son de 120×60. Para cambiar las dimensiones de la vista en Xcode 4.5 hay que seleccionar la vista, e ir a Attributes Inspector > Simulated Metrics > Size > Freeform. Podría haberlo escrito en código, pero la clase no habría quedado así de pequeña:

@interface PullView : UIView

@property (nonatomic,weak) IBOutlet UIView *topView;
@property (nonatomic,weak) IBOutlet UILabel *topLabel;
@property (nonatomic,weak) IBOutlet UIImageView *topArrow;

@property (nonatomic,weak) IBOutlet UIView *bottomView;
@property (nonatomic,weak) IBOutlet UILabel *bottomLabel;
@property (nonatomic,weak) IBOutlet UIImageView *bottomArrow;

@end

#import "PullView.h"
@implementation PullView
-(void) awakeFromNib {
    _topLabel.text = NSLocalizedString(@"pull2view.pull.to.refresh", nil);
    _bottomLabel.text = [NSString stringWithFormat:@"%@: %@",
                         NSLocalizedString(@"pull2view.last.updated", nil),
                         NSLocalizedString(@"pull2view.last.updated.never",nil)];
}
@end

PullToRefreshVC

Esta será una subclase de UITableViewController con el código necesario para implementar la funcionalidad de refresco.

Lo primero que voy a hacer es cargar el NIB con el PullView y desplazar su frame para colocarlo sobre el tableView. El objetivo es que al tirar de la tabla, aparezca la vista que creamos antes.

-(void) viewDidLoad {
    [super viewDidLoad];
    [self setupPullToRefresh];
}

-(void) setupPullToRefresh
{
    _pullView = [[[NSBundle mainBundle] loadNibNamed:NSStringFromClass([PullView class]) owner:[PullView new] options:nil] objectAtIndex:0];
    _pullView.frame = CGRectOffset(_pullView.frame, 0, -_pullView.frame.size.height);
    [self.tableView addSubview:_pullView];
}

Ahora hay que ejecutar el refresco cuando el usuario tira hasta hacer visible el pullView y luego suelta. Esto es fácil porque UITableView subclasifica UIScrollView, que tiene una variable contentOffset que nos informa del movimiento de la tabla.

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
    bool isPullViewVisible = scrollView.contentOffset.y < -_pullView.frame.size.height;
    if (isPullViewVisible){
        [self refresca];
    }
}

El siguiente paso es mantener el pullView visible mientras el refresco se ejecuta. Esto se hace añadiendo y eliminando el contentInset, que es un margen entre el panel de la tabla y el panel del UIScrollView sobre el que descansa.

-(void) refresh {
    [UIView animateWithDuration:0.3 animations:^{
        [self.tableView setContentInset:UIEdgeInsetsMake(_pullView.frame.size.height, 0, 0, 0)];
    }];

    // ...refresca los datos...

    [UIView animateWithDuration:0.3 animations:^{
        [self.tableView setContentInset:UIEdgeInsetsZero];
    }];
}

Faltan todas las animaciones. Voy a animar la flecha circular para que se anime mientras el refresco se ejecuta y luego pare. Aquí está el código:

CATransform3D rotationTransform = CATransform3DMakeRotation(-M_PI_4, 0, 0, 1.0);
CABasicAnimation* rotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform"];
rotationAnimation.toValue = [NSValue valueWithCATransform3D:rotationTransform];
rotationAnimation.duration = 0.15f;
rotationAnimation.cumulative = YES;
rotationAnimation.repeatCount = HUGE_VALF;
[_pullView.topArrow.layer addAnimation:rotationAnimation forKey:@"rotationAnimation"];

// ... refresco ...

// elimina la animación y restaura la posición original
[_pullView.topArrow.layer removeAllAnimations];
[_pullView.topArrow layer].transform = CATransform3DMakeRotation(0, 0, 0, 1);

Ya solo falta actualizar el texto y la flecha de abajo. El método scrollViewDidScroll: se llama repetidamente mientras la vista se desplaza. Dentro de él podemos usar la propiedad contentOffset para calcular el tanto por uno de la vista que está visible:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    bool isVisible = scrollView.contentOffset.y<0;
    if (isVisible) {
        CGFloat visibility = MIN(1.0, scrollView.contentOffset.y/-_pullView.frame.size.height);
        [self didPullToVisibility:visibility];
    }
}

El valor de visibility irá de 0 (oculto) a 1 (completamente visible). Actualizar la vista requiere unas cuantas líneas más:

// Cambia la flecha y el texto a medida que el usuario arrastra hacia abajo. 
-(void) didPullToVisibility:(CGFloat)visibility
{
    static bool wasFullyVisible = false;
    bool isFullyVisible = floorf(visibility);
    bool valueChanged = wasFullyVisible ^ isFullyVisible;
    if (valueChanged){

        // rota la flecha arriba si la vista está completamente visible, o abajo en caso contrario
        wasFullyVisible = isFullyVisible;
        [UIView animateWithDuration:0.2 animations:^{
            CGFloat angle = (int)wasFullyVisible * M_PI; // el valor es 0 o PI
            [_pullView.bottomArrow layer].transform = CATransform3DMakeRotation(angle, 0, 0, 1);
        }];

        // actualiza el texto
        _pullView.topLabel.text = isFullyVisible ? NSLocalizedString(@"pull2view.release.to.refresh", nil)
                                                 : NSLocalizedString(@"pull2view.pull.to.refresh", nil);
    }
}

Y eso es todo. Me he saltado unos cuantos detalles para hacerlo más breve, pero el proyecto completo está en Github comentado en insultante detalle. Te animo a que preguntes si algo no te ha quedado claro.

Cuando usarlo

Úsalo cuando sea intuitivo y fácil de descubrir.

Por ejemplo, si las filas de una tabla están ordenadas cronológicamente, con nuevas filas apareciendo arriba, es intuitivo arrastrar la tabla más allá de su posición para que aparezcan nuevas filas. Y es fácil descubrirlo porque el desplazamiento es el uso normal de una tabla. En cambio, si el gesto está desconectado del uso normal de la aplicación, el usuario tendrá que aprender y recordar su uso.

Las aplicaciones móviles se usan en intervalos cortos de tiempo, a la par que otras actividades. En este contexto la atención del usuario está dividida, y es esencial que el interfaz sea intuitivo (=invisible). El objetivo es que el usuario no piense en tu aplicación, sino en lo que puede hacer con ella.

¿Porqué rebotan las tablas?

La novedad de los interfaces táctiles es su similitud con el mundo real. Puedes tocarlos directamente y responden imitando las leyes de la física. Esto hace que su uso sea intuitivo y fácil de descubrir.

Las tablas por ejemplo, responden al arrastre con aceleración, inercia, deceleración, y rebote, tal como lo haría un objeto real en movimiento. Si intentamos arrastrar más allá de su posición, responde a nuestro gesto, pero vuelve a su posición para indicar que no hay más contenido.

La misma operación en un PC requiere desplazar la tabla arrastrando la barra con el ratón. Es decir, tirar de X usando Y para que ocurra Z. Esta interacción requiere aprendizaje y nos distrae de nuestra tarea real. En un móvil, nada mejor que usar los dedos.

Apple ha invertido mucho esfuerzo y atención al detalle para crear dispositivos que no necesitan manual. Por eso aprender a usar un iPad es un deleite, no un esfuerzo.

Acerca del Autor

Soy Alejandro Ramírez (@j4n0). Programé 10 años en Java y 2 en iOS. Los proyectos iOS se parecen más a crear un producto que a prestar un servicio, y tienden a ser más satisfactorios.