Alexandre Freire

Blog sobre desarrollo en Swift, iOS y Xcode

Cómo animar una cabecera personalizada mientras se hace scroll en Swift

Jun 15, 2020

Cuando utilizamos un UINavigationController tenemos una cabecera en donde suele ir el título y algunos botones. Es la llamada UINavigationBar.

Desde iOS 11 incluso tenemos la posibilidad de que ésta se pueda colapsar o extender según la cantidad de scroll que hace el usuario, con una animación implícita bastante lograda:

Sin embargo, cuando queremos personalizar un poco más el look & feel de nuestras apps, vemos que la navigationBar del UINavigationController se nos queda corta.

En este tutorial vamos a ver cómo implementar nuestra propia cabecera (Header) personalizada, y cómo esta se puede animar acompañando al scroll que hace el usuario. El resultado final al que queremos llegar es algo así:

collapse header UITableView

Herramientas que vamos a utilizar:

Para conseguir el efecto logrado en el GIF anterior, vamos a necesitar utilizar AutoLayout y un poco de matemáticas. No te preocupes si tienes tus matemáticas oxidadas porque vamos a utilizar unas funciones que es muy probable que ya hayas usado anteriormente:

  • Funciones min y max: aceptan dos parámetros numéricos y devuelven el valor mínimo y máximo respectivamente.
min(10, 42) 
// Devuelve 10

max(10, 42) 
// Devuelve 42
  • Propiedad transform de una UIView: todas las vistas de UIKit tienen una propiedad transform para aplicarle una transformación algebraica. Podemos utilizarla para rotar o escalar una vista, utilizando transformadas ya creadas y listas para usar:
// Creamos un UIImageView de tamaño 10x10 
let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 10, height: 10))

// Le aplicamos una transformada para escalarla, es decir, para
// modificar su tamaño
imageView.transform = CGAffineTransform(scaleX: 2, y: 2) 

// Ahora la imageView es de tamaño 20x20
  • AutoLayout: utilizaremos «constraints» para crear el layout de la nueva Header y modificaremos sus valores
let headerHeightConstraint: NSLayoutConstraint = ...

// Modificamos el valor de la constraint que define la altura del Header
headerHeighConstraint.constant = 100 
  • Propiedad contentInset de un UIScrollView: puedes pensar en él como el padding de CSS, es decir, cuánto espacio a cada lado hay que dejar.
let scrollView = UIScrollView(frame: ...) 
// UIScrollView que mide 100 x 500

scrollView.contentInset = UIEdgeInsets(top: 200, left: 0, bottom: 0, right: 0) 
// Añades un espacio de 200 puntos en la parte superior del scrollView
  • Matemáticas y cálculos de porcentajes.

Paso 1: Creando y configurando la UI

Imagina que estamos creando la app de este blog, y queremos tener una lista de posts con una cabecera similar a la de la página web.

Crear el PostListViewController

En primer lugar, debemos crear un nuevo UIViewController al que llamaremos PostListViewController. Después, mediante el InterfaceBuilder añadimos un UITableView, «pineada» a su superview, y una UIView encima, que será la cabecera personalizada:

Cabecera scrollable

Fíjate que no está primero la cabecera y después la tabla, si no que en primer lugar está la UITableView pegada a los extremos de su supervista y después, encima de la tableView, tenemos la vista de la cabecera ocultando parte de su contenido. Esto lo arreglaremos por código en un momento.

Crear la clase de la cabecera: BlogHeaderView

Después, añade un nuevo fichero y dale el nombre de BlogHeaderView. Como puedes observar en la imagen anterior, éste dispondrá de tres UILabel y dos UIImageView. Las propiedades serán private(set), de manera que sean «readonly» desde otros objetos.

final class BlogHeaderView: UIView {
    @IBOutlet private(set) var titleLabel: UILabel!
    @IBOutlet private(set) var subtitleLabel: UILabel!
    @IBOutlet private(set) var scheduleLabel: UILabel!
    @IBOutlet private(set) var leftIconView: UIImageView!
    @IBOutlet private(set) var rightIconView: UIImageView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }
    
    private func setupUI() {
        titleLabel = UILabel()
        subtitleLabel = UILabel()
        scheduleLabel = UILabel()
        leftIconView = UIImageView()
        rightIconView = UIImageView()
    }
}

Conectar los @IBOutlets con los ficheros de las clases:

Colócate en el PostListViewController.xib (o Storyboard) y conecta un @IBOutlet para el tableView y otro para el headerView. También crea un @IBOutlet para la AutoLayout constraint que define la altura del header, ya que lo necesitaremos para más adelante:

final class PostListViewController: UIViewController {

    // MARK: - Outlets
    @IBOutlet private weak var headerView: BlogHeaderView!
    @IBOutlet private weak var tableView: UITableView!
    @IBOutlet private weak var headerConstraint: NSLayoutConstraint!

    .... 

}

Por supuesto, también tienes que conectar los @IBOutles de los UILabel y UIImageView de la cabecera con la clase BlogHeaderView.

Paso 2: Crear el objeto que se encargará de la animación:

Crea un nuevo fichero Swift llamado BlogHeaderCollapsingAnimator y añade este código:

final class BlogHeaderCollapsingAnimator {
    private let headerView: BlogHeaderView
    private let heightConstraint: NSLayoutConstraint
    
    private var maxHeaderHeight: CGFloat
    
    // 1
    init(
        headerView: BlogHeaderView,
        heightConstraint: NSLayoutConstraint
    ) {
        self.headerView = headerView
        self.heightConstraint = heightConstraint
        
        maxHeaderHeight = heightConstraint.constant
    }
    
    // 2
    func scrollViewDidScroll(offset: CGPoint) {
        
        let offsetY: CGFloat = offset.y + maxHeaderHeight
        print(offsetY)
    }
}
  • // 1: Creamos un método init al que le pasamos la vista de la cabecera y la AutoLayout constraint de la altura de la misma. También asignamos la propiedad maxHeaderHeight como la constante de la heightConstraint, ya que vamos a asumir que ésta se dibuja en el estado «expandido» dentro del PostListViewController.xib.
  • // 2: Creamos un método público al que se le pasa la cantidad de puntos que el usuario ha hecho scroll como argumento, creamos una propiedad offsetY que representa la cantidad de scroll vertical, y ponemos un print.

A la propiedad offsetY se le suma el valor de maxHeaderHeigh ya que vamos a añadirle un contentInset (o padding o espacio extra) igual al valor de la altura de la cabecera.

Paso 3: Crear el objeto BlogHeaderCollapsingAnimator en el PostListViewController:

Abre el PostListViewController y añade el siguiente código:

final class PostListViewController: UIViewController {

    // MARK: - Outlets
    ...
    
    // MARK: - Properties
    // 1
    private var headerAnimator: BlogHeaderCollapsingAnimator!
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        // Resto de UI setup, tableView dataSource setup, etc...
        ...

        // 2
        navigationController?.isNavigationBarHidden = true
        tableView.delegate = self
           
        // 3
        headerAnimator = BlogHeaderCollapsingAnimator(
            headerView: headerView,
            heightConstraint: headerConstraint
        )

        // 4
        tableView.contentInsetAdjustmentBehavior = .never
        tableView.contentInset = UIEdgeInsets(top: headerConstraint.constant , left: 0, bottom: 0, right: 0)
    }
}

extension PostListViewController: UITableViewDelegate {
    // 5
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        headerAnimator.scrollViewDidScroll(offset: scrollView.contentOffset)
    }
}

// Implementación de UITableViewDataSource
...
  • // 1: Añadimos una propiedad de tipo BlogHeaderCollapsingAnimator opcional explícitamente desempaquetada, ya que se trata de una propiedad que tenemos que instanciar cuando la UI se ha terminado de cargar. Se trata de un «late init».
  • // 2: Ocultamos la navigationBar del UINavigationController en caso te tenerla, para evitar que se vean las dos cabeceras juntas, y asignamos el delegado del tableView
  • // 3: Es hora de crear el headerAnimator, pasándole la view y la constraint de la altura por parámetro.
  • // 4: Añadimos el padding para crear un espacio en blanco en la parte superior de la tableView, un espacio que ocupará la cabecera. El primer elemento de la tabla, por lo tanto, ya no estará oculto debajo de la cabecera, si no perfectamente visible. También configuramos la propiedad contentInsetAdjustmentBehavior como .never para que no se tenga en cuenta la safeArea a la hora de calcular el contentInset de la tabla.
  • // 5: implementamos el método del delegado de tableView que nos informa de que el usuario ha hecho scroll. Propagamos esa información al headerAnimator.

Si ahora ejecutas la app, a medida que hagas scroll la consola te imprimirá la catidad de scroll que el usuario ha hecho (en tu consola verás valores diferentes):

4.0
13.5
23.0
32.0
42.0
53.0
64.0
75.0
...
...

Paso 4: Implementar la animación

Ya está todo configurado para poder animar la cabecera a medida que el usuario hace scroll. Es hora de desempolvar tus skills de álgebra y matemáticas y realizar las animaciones. En total, habrá 4 animaciones:

  1. La altura de la cabecera
  2. El tamaño de fuente de titleLabel (el que tiene el texto "AlexandreFreire")
  3. La transparencia (propiedad alpha) del scheduleLabel (el que tiene el texto "Nuevo posts todas las semanas"
  4. El tamaño de los iconos leftIconView (Swift) y rightIconView (Apple)

Definiendo los valores máximos y mínimos de la cabecera

Abre el fichero BlogHeaderCollapsingAnimator y crea una extensión privada de BlogHeaderView en la última línea del fichero. En ella, definiremos una serie de constantes que definirán:

  • Altura mínima de la cabecera: por mucho que el usuario haga scroll-down (hacia abajo), la altura de la cabecera nunca será más pequeña que este valor.
  • Tamaño de fuente máximo y mínimo del titleLabel: por mucho que el usuario haga scroll-down, el tamaño de fuente nunca será inferior a minFontSize, y por mucho que haga scroll-up (hacia arriba), el tamaño de fuente nunca será superior a maxFontSize
  • Tamaño máximo y mínimo de los iconos, dependiendo de cuánto scroll haga el usuario.
final class BlogHeaderCollapsingAnimator {
    ...
}

private extension BlogHeaderView {
    
    static let minHeight: CGFloat = 121
    
    enum TitleLabel {
        static let maxFontSize: CGFloat = 40
        static let minFontSize: CGFloat = 22
    }
    
    enum Icon {
        static let minSize: CGFloat = 45
        static let maxSize: CGFloat = 70
    }
}

Animando la altura de la cabecera

Una vez creado los valores anteriores, sitúate en el método scrollViewDidScroll(offset:), borra el print y escribe lo siguiente:

func scrollViewDidScroll(offset: CGPoint) {
        
        let offsetY: CGFloat = offset.y + maxHeaderHeight
         // 1 
        let currentHeight = max(
            BlogHeaderView.minHeight,
            maxHeaderHeight - offsetY
        )

        // 2
        heightConstraint.constant = currentHeight

         // Más código aquí en un momento
    }
  • // 1: Calculamos la altura que debería de tener la cabecera dependiendo de la cantidad de scroll que se haya hecho. Dado que la altura de la cabecera no puede ser inferior a BlogHeaderView.minHeight, nos quedamos con el valor máximo entre éste y el tamaño máximo de la cabecera menos la cantidad de scroll.
  • // 2: Asignamos el valor calculado como constante de la heightConstraint

Si ahora ejecutas el código, verás que el tamaño de la cabecera varía en función de la cantidad de scroll que hace el usuario.

Es hora de animar el tamaño de fuente del UILabel principal…

Animando el tamaño de fuente del titleLabel

En el método scrollViewDidScroll(offset:) borra el comentario // Más código aquí en un momento y sustitúyelo por:

func scrollViewDidScroll(offset: CGPoint) {
        ...

        // 1
        let scaleFactor = currentHeight / maxHeaderHeight
        // 2
        updateTitleLabelFontSize(using: scaleFactor)
    }
  • // 1: Calculamos el factor de escala, dividiendo la altura actual de la cabecera entre la máxima altura posible. Eso nos dará un valor entre 0 y 1, representando un porcentaje, y lo usaremos para modificar los valores de las siguientes animaciones.
  • // 2: Llamamos a la función para actualizar el tamaño de fuente usando el factor de escala. Implementaremos este método ahora mismo

Ahora implementaremos el método updateTitleLabelFontSize(using:) para actualizar el tamaño de la fuente. Podemos crear estos métodos en una extensión:

extension BlogHeaderCollapsingAnimator {
    private func updateTitleLabelFontSize(using scaleFactor: CGFloat) {
        let fontSize = makeLabelFontSize(using: scaleFactor)
        headerView.titleLabel.font = headerView.titleLabel.font.withSize(fontSize)
    }

    private func makeLabelFontSize(using scaleFactor: CGFloat) -> CGFloat {
        // 1
        let scaledFontSize = scaleFactor * BlogHeaderView.TitleLabel.maxFontSize
        
        // 2
        let fontSizeAfterScrolling = max(
            BlogHeaderView.TitleLabel.minFontSize,
            scaledFontSize
        )

        return fontSizeAfterScrolling
    }
}
  • // 1: Aplicamos el factor de escala al tamaño máximo de fuente.
  • // 2: El tamaño del UILabel será el valor máximo entre el scaledFontSize y el valor mínimo permitido.

Animando la transparencia de scheduleLabel:

En esta sección vamos a utilizar los conocimientos sobre porcentajes. ¿Se te daban bien en la escuela? 😃

Dentro de la misma extensión, crea los siguientes métodos para actualizar la propiedad alpha del scheduleLabel:

extension BlogHeaderCollapsingAnimator {
    ...

    private func updateScheduleLabelAlpha(using scaleFactor: CGFloat) {
        let alpha = makeAlphaFactor(using: scaleFactor)
        headerView.scheduleLabel.alpha = alpha
    }
    
    private func makeAlphaFactor(using scaleFactor: CGFloat) -> CGFloat {
        // 1
        let totalReductionFactor = BlogHeaderView.minHeight / maxHeaderHeight
        // 2
        let triggerValue: CGFloat = 1
        
        guard scaleFactor < triggerValue else {
            return 1
        }
        
        // 3
        let totalRangeAffectingAlpha = triggerValue - totalReductionFactor
        let offsetFromTrigger = triggerValue - scaleFactor
        
        // 4
        let alpha = 1 - (offsetFromTrigger / totalRangeAffectingAlpha)
        
        return alpha
    }
}

Aquí es donde tienes que refrescar tus clases de matemáticas de la escuela, calculando porcentajes:

  • // 1: Calculamos el % total de reducción de la altura de la cabecera.
  • // 2: Definimos el valor desde el cual empezará la animación. Como quiero que se empiece a animar desde el primer momento, pongo 1. Si quisiese que durante el primer 20% de recorrido no se anime, el triggerValue sería 0.8.
  • // 3: Configuramos el rango en el que queremos que se complete la animación, así como la cantidad de scroll respecto al triggerValue.
  • // 4: Calculamos y devolvemos el valor de alpha.

En la función scrollViewDidScroll(offset:) llama al método updateScheduleLabelAlpha(using:):

func scrollViewDidScroll(offset: CGPoint) {
        ...

        let scaleFactor = currentHeight / maxHeaderHeight
        updateTitleLabelFontSize(using: scaleFactor)
        updateScheduleLabelAlpha(using: scaleFactor)
    }

Si ejecutas ahora la app, verás que la cabecera ya tiene una animación bastante curiosa. Incluso sin haber definido cómo se escalan los iconos, el tamaño de éstos también se animan gracias a cómo se han configurado las AutoLayout constraints.

Sin embargo, si queremos tener más control sobre cómo se anima el tamaño de los iconos, lee el siguiente apartado.

Animando los iconos

En el método scrollViewDidScroll(offset:) añade lo siguiente:

func scrollViewDidScroll(offset: CGPoint) {
        ...

        let scaleFactor = currentHeight / maxHeaderHeight
        updateTitleLabelFontSize(using: scaleFactor)
        updateScheduleLabelAlpha(using: scaleFactor)
        updateIconSize(using: scaleFactor)
    }

E implementa este nuevo método updateIconSize(using:) dentro de la extensión:

extension BlogHeaderCollapsingAnimator {
    ...
  
    func updateIconSize(using scaleFactor: CGFloat) {
        // 1
        let iconScaleFactor = makeIconScaleFactor(for: scaleFactor)

        // 2
        headerView.leftIconView.transform = CGAffineTransform(scaleX: iconScaleFactor, y: iconScaleFactor)
        headerView.rightIconView.transform = CGAffineTransform(scaleX: iconScaleFactor, y: iconScaleFactor)
    }
    
    func makeIconScaleFactor(for scaleFactor: CGFloat) -> CGFloat {
        guard scaleFactor < 1 else {
            return 1
        }
        
        let scaledSize = scaleFactor * BlogHeaderView.Icon.maxSize
        
        let iconSizeAfterScrolling =  max(
            scaledSize,
            BlogHeaderView.Icon.minSize
        )
        
        return iconSizeAfterScrolling / BlogHeaderView.Icon.maxSize
    }
}
  • // 1: Calculamos el factor de escala del icono, dividiendo el tamaño actual después de hacer scroll entre el tamaño máximo del icono.
  • // 2: Aplicamos una transformada CGAffineTransform(scaleX:y:) utilizando el factor de escala previamente calculado.

Únete a la newsletter para no perderte ningún nuevo tutorial: recibe un email cuando se publiquen nuevos artículos o vídeos y no pierdas la oportunidad de seguir aprendiendo. 

Conclusión

Como puedes observar, no es tan difícil crear una cabecera personalizada que sustituya a la que viene por defecto en los UINavigationController.

Recuerda que, si haces un push y quieres que se te vea la navigationBar por defecto, tendrás que controlar su visibilidad mediante navigationController?.isNavigationBarHidden = true // o false

Si tienes cualquier duda, no dudes en dejarme un comentario por aquí abajo.

Y si te ha resultado útil, también puedes dejarme uno con tu feedback 🙂 Me anima mucho leer vuestros emails o comentarios. y saber que mis artículos os están gustando.

👇👇👇

¿Me ayudas a compartir en redes sociales?
Share on Facebook
Facebook
Pin on Pinterest
Pinterest
Tweet about this on Twitter
Twitter
Share on LinkedIn
Linkedin
Buffer this page
Buffer
Share on Reddit
Reddit

4 Comentarios

  1. Adrian

    Muy bueno el post Xandre!!

    Responder
  2. Martin

    Excelente tutorial Alexandre, seguiremos aprendiendo contigo!! 🙂

    Responder
    • Alexandre Freire

      Muchas gracias Martín!
      Me alegro que te estén gustando los tutoriales 😄

      Responder

Enviar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *