Vertical tabBar para iOS mediante «UIViewController Containment»

Autor: | Última modificación: 15 de abril de 2024 | Tiempo de Lectura: 6 minutos
Temas en este post:
¿Te has preguntado cómo harán esos controles que aparecen en las killer apps y que a las pocas horas tienen una versión open source en github? A partir de iOS 5 se introdujo el concepto de container view controller, y ahora hablamos siempre del UIViewController Containment.

¿Y qué es eso del container view controller?

Básicamente nos permite añadir controladores dentro de otro controlador creando una jerarquía, de modo que podemos descomponer nuestra app en partes y cada una está controlada por su propio controlador.

Pero… ¿cómo es posible?

Lo que ocurre under the hood es que nosotros añadimos la vista de esos controladores a nuestra vista contenedora y el controlador padre, que previamente ha añadido los controladores a su jerarquía, sabe a qué controlador hace referencia esa vista que es quien la manejará. Si entendiste Inception a la primera, esto estará chupao 🙂 Apple nos proporciona unos cuantos por defecto, como por ejemplo UINavigationController, UITabBarController, UISplitViewController… Hacer esto no es gratis, vamos a tener que dedicar un tiempo a diseñar nuestro container view controller, pero si queremos destacar en el App Store con algo original vamos a tener que hacerlo.

Implementando nuestro container view controller

Para demostrar que no es tan complejo como parece vamos a hacer un pequeño ejemplo sobre cómo realizar un tabBar en posición vertical. En la siguiente imagen vemos un esquema con los objetos que vamos a utilizar:
Estructura vertical tabBar_UIViewController Containment
Relación de objetos a crear para implementar nuestro container view controller, un tabBar vertical
Para explicar el funcionamiento de cada componente vamos a empezar desde el nivel más bajo hasta el controlador principal que encapsula todo el comportamiento:

AGTVerticalTabBarItem

Hereda de NSObject y representa al elemento que, al interactuar con él, mostrará su view controller asociado. Sus propiedades por tanto serán un título, un botón y un view controller:
@interface AGTVerticalTabBarItem : NSObject

@property(nonatomic, copy) NSString *title;
@property(nonatomic, strong) UIViewController *controller;
@property(nonatomic, strong) UIButton *button;

+ (id)itemWithTitle:(NSString *)title controller:(UIViewController *)viewController;
- (id)initWithTitle:(NSString *)title controller:(UIViewController *)viewController;

@end
La implementación es trivial.

AGTVerticalTabBarView

Hereda de UIView y representa el tabBar vertical (pegado a la izquierda). Se compone de un array de AGTVerticalTabBarItem y el elemento e índice de ese array seleccionado. También declara un protocolo que llamará cada uno de los AGTVerticalTabBarItem que pegará un grito a quien lo implemente (veremos más adelante quien es el pringao).
@class AGTVerticalTabBarView;

@protocol AGTVerticalTabBarDelegate <NSObject>

- (void)tabBar:(AGTVerticalTabBarView *)tabBar didSelectTabAtIndex:(NSUInteger)index;

@end

@interface AGTVerticalTabBarView : UIView

@property(nonatomic, weak) id delegate;
@property(nonatomic, copy) NSArray *tabBarButtons;
@property(nonatomic, weak) UIButton *selectedTabBarButton;

@end
En la implementación sobrescribiremos el getter de tabBarButtons para asignarles a todos una acción cuando sean pulsados (para que puedan notificador a su delegate y que curre él) además de marcar uno como seleccionado por defecto, en nuestro caso, como somos muy ordenados, será el primero ;).
- (void)setTabBarButtons:(NSArray *)buttons
{
    // borramos los anteriores por si los cambiamos dinamicamente
    for (id button in _tabBarButtons) {
        [button removeFromSuperview];
    }

    _tabBarButtons = buttons;

    for (id button in _tabBarButtons) {
        // les añadimos una accion a los botones para responder a la interaccion, llamaremos al delegado y el controlador cambiara el VC
        [button addTarget:self action:@selector(selectTabIfAllowed:) forControlEvents:UIControlEventTouchDown];
    }

    if (_tabBarButtons.count > 0) {
        // marcamos el primer boton por defecto
        [[_tabBarButtons objectAtIndex:0] setSelected:YES];
        self.selectedTabBarButton = [_tabBarButtons objectAtIndex:0];

        // avisamos al delegado
        if (self.delegate && [self.delegate respondsToSelector:@selector(tabBar:didSelectTabAtIndex:)]) {
            [self.delegate tabBar:self didSelectTabAtIndex:0];
        }
    }

    [self setNeedsLayout];
}

- (void)selectTabIfAllowed:(id)sender
{
    // solo si no somos el mismo seleccionado
    if (self.selectedTabBarButton != sender) {
        for (id item in self.tabBarButtons) {
            [item setSelected:NO];
        }
        [sender setSelected:YES];
        self.selectedTabBarButton = sender;

        if (self.delegate && [self.delegate respondsToSelector:@selector(tabBar:didSelectTabAtIndex:)]) {
            [self.delegate tabBar:self didSelectTabAtIndex:[self.tabBarButtons indexOfObject:sender]];
        }
    }
}
Sobrescribimos el método layoutSubviews para que se dibujen los items (su representación visual es un UIButton) centrados en la vertical.

AGTVerticalTabBarControllerView

Hereda de UIView y encapsula la vista del tabBar y el resto de la vista de la pantalla donde le encasquetaremos la vista del view controller seleccionado en el tabBar. La funcionalidad de esta clase es básicamente hacer el layout con las dos vistas comentadas anteriormente y hacer las transiciones entre las vistas que nos va a pasar el controlador (que englobará todo y aún no lo hemos presentado). Hemos hecho una animación sencilla con los métodos de UIView ya que no es el tema del post.
@interface AGTVerticalTabBarControllerView ()

@property(nonatomic, strong) AGTVerticalTabBarView *tabBarView;
@property(nonatomic, strong) UIView *contentView;

@end

@implementation AGTVerticalTabBarControllerView

- (id)initWithFrame:(CGRect)frame
{
    if ((self = [super initWithFrame:frame])) {
        // creamos el tabBar
        CGRect tabBarFrame = CGRectMake(0, 0, TAB_BAR_WIDHT, self.frame.size.height);
        _tabBarView = [[AGTVerticalTabBarView alloc] initWithFrame:tabBarFrame];
        [self addSubview:self.tabBarView];
    }
    return self;
}

- (void)setContentView:(UIView *)newContentView animated:(BOOL)animated
{
    if (!_contentView) {
        // si no hay contentView es porque acabamos de arrancar y tenemos que asignar la primera vista
        // contentView tendra el contenido de la view del VC en cuestion
        newContentView.frame = CGRectMake(TAB_BAR_WIDHT, 0, self.bounds.size.width - TAB_BAR_WIDHT, self.bounds.size.height);
        _contentView = newContentView;
        [self addSubview:_contentView];
        [self sendSubviewToBack:_contentView];
    }
    else {
        UIView *oldContentView = _contentView;

        if (!animated) {
            // si lo queremos sin animacion, nosotros le hemos dicho a piñon que si, pero lo dejamos al gusto
            newContentView.frame = CGRectMake(TAB_BAR_WIDHT, 0, self.bounds.size.width - TAB_BAR_WIDHT, self.bounds.size.height);
            _contentView = newContentView;
            [self addSubview:_contentView];
            [self sendSubviewToBack:_contentView];
            [oldContentView removeFromSuperview];
        }
        else {
            // lo vamos a desplazar de izquierda a derecha
            CGFloat xAmount = -_contentView.frame.size.width;

            newContentView.frame = oldContentView.frame;

            // ajustamos la posicion x
            newContentView.frame = CGRectMake(xAmount + TAB_BAR_WIDHT, newContentView.frame.origin.y, newContentView.frame.size.width, newContentView.frame.size.height);

            _contentView = newContentView;

            [self addSubview:_contentView];
            [self sendSubviewToBack:_contentView];

            self.userInteractionEnabled = NO;

            [UIView animateWithDuration:0.3
                                  delay:0
                                options:UIViewAnimationOptionCurveEaseOut
                             animations:^{
                                 oldContentView.frame = CGRectMake(oldContentView.frame.origin.x - xAmount, oldContentView.frame.origin.y, oldContentView.frame.size.width, oldContentView.frame.size.height);
                                 newContentView.frame = CGRectMake(newContentView.frame.origin.x - xAmount, newContentView.frame.origin.y, newContentView.frame.size.width, newContentView.frame.size.height);
                             }
                             completion:^(BOOL finished) {
                                 [oldContentView removeFromSuperview];
                                 [_contentView setNeedsLayout];
                                 self.userInteractionEnabled = YES;
                             }];
        }
    }
}

@end

AGTVerticalTabBarController a.k.a UIViewController containment

La cabeza visible. Hereda de UIViewController y su función es la de encapsular todo el componente y dar una API sencilla al que vaya a utilizar este container view controller (en nuestro caso hemos realizado un compartamiento muy básico, se trataría de ir ampliándolo en función de las necesidades). Recibe un NSArray de AGTVerticalTabBarItem que propagará a la instancia de AGTVerticalTabBarView que crea en el método loadView (sobrescribiendo este método logramos que, como self.view será nil al no tener asociado ningún xib, se llame este método y nosotros nos creamos nuestra vista personalizada, en este caso creando una instancia de AGTVerticalTabBarControllerView).
- (void)loadView
{
    // sobrescribimos el método de cargar la vista para crearla a nuestra manera
    CGRect mainFrame = [UIScreen mainScreen].applicationFrame;
    CGRect viewFrame = CGRectMake(0.0f, STATUS_BAR_HEIGHT, mainFrame.size.width, mainFrame.size.height);

    // como vista vamos a asignar nuestra propia vista personalizada en vez de una UIView
    // esta vista contiene tanto el tabBar como la vista en si
    self.view = [[AGTVerticalTabBarControllerView alloc] initWithFrame:viewFrame];

    // extraemos los botones que contienen los AGTVerticalTabBarItem para pasarselos a la vista del tabBar
    NSMutableArray *buttons = [NSMutableArray arrayWithCapacity:self.tabBarItems.count];

    for (NSUInteger i = 0; i < self.tabBarItems.count; i++) {
        AGTVerticalTabBarItem *item = [self.tabBarItems objectAtIndex:i];
        [buttons addObject:item.button];
    }

    // recuperamos la vista del tabBar, le asignamos los botones y nos hacemos su delegado
    AGTVerticalTabBarView *tabBarView = [(AGTVerticalTabBarControllerView *)self.view tabBarView];
    tabBarView.tabBarButtons = buttons;
    tabBarView.delegate = self;

    // marcamos el viewController actual, por defecto el 0
    [self setSelectedIndex:self.lastSelectedIndex];
}
La magia de porque podemos tener varios controladores dentro de otro sucede cuando le asignamos el NSArray de AGTVerticalTabBarItem (el cual, recordemos, tiene un UIViewController entre sus propiedades). Lo que hacemos es añadir en nuestro controlador, que es el que encapsula todo, los UIViewController. Según la documentación de la gente de Cupertino, la inserción y la eliminación de un controlador hijo se realiza en dos pasos:
- (void)setTabBarItems:(NSArray *)tabBarItems
{
    if (_tabBarItems != tabBarItems) {
        // cada vez que nos asignen nuevos items, los eliminamos
        for (AGTVerticalTabBarItem *item in _tabBarItems) {
            [item.controller willMoveToParentViewController:nil];
            [item.controller removeFromParentViewController];
        }

        // y los añadimos a la jerarquia de viewControllers
        _tabBarItems = tabBarItems;
        for (AGTVerticalTabBarItem *item in tabBarItems) {
            [self addChildViewController:item.controller];
            [item.controller didMoveToParentViewController:self];
        }
    }
}
Este es el pringao que hace todo el curro, por lo que también implementa el protocolo que ha declarado AGTVerticalTabBarView lo que provocará que cambie el view controller seleccionado:
- (void)tabBar:(AGTVerticalTabBarController *)tabBar didSelectTabAtIndex:(NSUInteger)index
{
    UIViewController *viewController = [[self.tabBarItems objectAtIndex:index] controller];
    self.selectedViewController = viewController;
}
Una vez ya sabemos que controlador queremos mostrar (como hemos visto al principio lo que en realidad vamos a hacer es insertar la vista de ese controlador y automágicamente la va a gestionar su controlador) hemos sobrescrito el método selectedViewController para llamar a nuestra vista (que es AGTVerticalTabBarControllerView), que asigne el nuevo contenido y ademas lo anime (como hemos visto en su apartado):
- (void)setSelectedViewController:(UIViewController *)newViewController
{
    // si están seleccionando el mismo VC no hacemos nada
    if (_selectedViewController == newViewController) {
        return;
    }

    _selectedViewController = newViewController;

    // le decimos a nuestra vista (AGTVerticalTabBarControllerView) que pinte la vista del nuevo VC animandolo
    [(id)self.view setContentView:_selectedViewController.view animated:YES];

    // nos guardamos el ultimo indice seleccionado y marcamos la seleccion en el boton correspondiente (para cambiar icono por ejemplo)
    AGTVerticalTabBarItem *item;
    for (NSUInteger i = 0; i < self.tabBarItems.count; ++i) {
        item = [self.tabBarItems objectAtIndex:i];
        [item.button setSelected:(item.controller == _selectedViewController)];
        if (item.button.selected) {
            self.lastSelectedIndex = i;
        }
        [item.button setNeedsDisplay];
    }
}
Para hacer funcionar todo esto habría que crear una instancia de AGTVerticalTabBarController y pasarle tantos AGTVerticalTabBarItem como deseemos. Aunque todo esto parezca bastante lioso en texto, si entendemos la relación que hay entre vistas y controladores, es simplemente cuestión de tener imaginación para crear el próximo controlador de controladores que sea trending! Tenéis el código disponible en github . Cualquier corrección, sugerencia, mejora…¡es bienvenida!