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í:
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
ymax
: 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 unaUIView
: todas las vistas deUIKit
tienen una propiedadtransform
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 unUIScrollView
: 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:
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 latableView
, 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étodoinit
al que le pasamos la vista de la cabecera y la AutoLayout constraint de la altura de la misma. También asignamos la propiedadmaxHeaderHeight
como la constante de laheightConstraint
, ya que vamos a asumir que ésta se dibuja en el estado «expandido» dentro delPostListViewController.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 propiedadoffsetY
que representa la cantidad de scroll vertical, y ponemos unprint
.
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 tipoBlogHeaderCollapsingAnimator
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 lanavigationBar
delUINavigationController
en caso te tenerla, para evitar que se vean las dos cabeceras juntas, y asignamos el delegado deltableView
// 3
: Es hora de crear elheaderAnimator
, 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 latableView
, 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 propiedadcontentInsetAdjustmentBehavior
como.never
para que no se tenga en cuenta lasafeArea
a la hora de calcular elcontentInset
de la tabla.// 5
: implementamos el método del delegado detableView
que nos informa de que el usuario ha hecho scroll. Propagamos esa información alheaderAnimator
.
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:
- La altura de la cabecera
- El tamaño de fuente de
titleLabel
(el que tiene el texto"AlexandreFreire"
) - La transparencia (propiedad
alpha
) delscheduleLabel
(el que tiene el texto"Nuevo posts todas las semanas"
- El tamaño de los iconos
leftIconView
(Swift) yrightIconView
(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 aminFontSize
, y por mucho que haga scroll-up (hacia arriba), el tamaño de fuente nunca será superior amaxFontSize
- 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 aBlogHeaderView.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 laheightConstraint
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 entre0
y1
, 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 delUILabel
será el valor máximo entre elscaledFontSize
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, pongo1
. Si quisiese que durante el primer 20% de recorrido no se anime, eltriggerValue
sería0.8
.// 3
: Configuramos el rango en el que queremos que se complete la animación, así como la cantidad de scroll respecto altriggerValue
.// 4
: Calculamos y devolvemos el valor dealpha
.
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 transformadaCGAffineTransform(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.
👇👇👇