tutoriales.com

Maestría en Custom Views y Controles en iOS: Creando Componentes Reusables con Swift y UIKit

Este tutorial te guiará a través del fascinante mundo de la creación de vistas y controles personalizados en iOS utilizando Swift y UIKit. Aprenderás a diseñar componentes visuales que se adapten perfectamente a tus necesidades, desde el dibujo personalizado hasta la gestión avanzada de eventos y la creación de APIs flexibles.

Intermedio20 min de lectura9 views
Reportar error

🚀 Introducción a las Custom Views y Controles en iOS

En el desarrollo de aplicaciones iOS, a menudo nos encontramos con la necesidad de funcionalidades o interfaces de usuario que no están cubiertas por los componentes estándar de UIKit. Aquí es donde entran en juego las Custom Views y los Custom Controls. Al dominar su creación, no solo podrás implementar diseños únicos, sino también construir una biblioteca de componentes reusables que aceleren tu flujo de trabajo y mejoren la consistencia de tu UI.

Este tutorial te equipará con los conocimientos y herramientas necesarios para crear tus propios componentes, desde los fundamentos del ciclo de vida de la vista hasta técnicas avanzadas de dibujo y gestión de interacción. ¡Prepárate para llevar tus habilidades de UI/UX al siguiente nivel!

💡 Consejo: La creación de custom views no solo es para diseños complejos. También es útil para agrupar lógicas de UI y presentación, haciendo tu código más modular y fácil de mantener.

🎯 ¿Por qué crear Custom Views y Controles?

La creación de componentes personalizados ofrece múltiples beneficios para cualquier proyecto iOS:

  • Flexibilidad de Diseño: Implementa cualquier diseño, por complejo o único que sea, sin las limitaciones de los componentes predefinidos.
  • Reusabilidad: Crea componentes que puedes utilizar en múltiples pantallas o incluso en diferentes proyectos, ahorrando tiempo y esfuerzo.
  • Consistencia de UI/UX: Asegura que tu aplicación tenga una apariencia y un comportamiento coherentes, mejorando la experiencia del usuario.
  • Modularidad: Encapsula la lógica de UI en unidades independientes, haciendo el código más limpio, mantenible y testeable.
  • Optimización del Rendimiento: En algunos casos, puedes optimizar el rendimiento dibujando solo lo que necesitas o implementando lógicas de renderizado más eficientes.

Custom View vs. Custom Control: ¿Cuál es la diferencia? 🤔

Si bien los términos a menudo se usan indistintamente, hay una distinción sutil pero importante en UIKit:

CaracterísticaUIView (Custom View)UIControl (Custom Control)
---------
Propósito PrincipalMostrar contenido y organizar subviewsRecibir y responder a eventos de usuario (taps, swipes)
Clase BaseUIViewUIControl (que hereda de UIView)
---------
InteractividadInteractividad básica a través de UIGestureRecognizerManejo de eventos target-action integrado
EstadosNo tiene estados intrínsecosPuede tener estados (normal, highlighted, selected, disabled)
---------
EjemplosTarjetas personalizadas, gráficos de datos, loadersBotones personalizados, sliders, interruptores, selectores

En resumen, todos los UIControl son UIView, pero no todas las UIView son UIControl. Si tu componente interactúa directamente con el usuario y necesita responder a eventos de forma específica (como un botón que se ilumina al tocarlo), UIControl es la base adecuada. Si solo necesitas mostrar contenido, UIView es suficiente.


🛠️ Fundamentos: Creando tu Primera Custom View (UIView)

Comencemos con algo básico: una UIView personalizada que muestre un texto y un color de fondo específicos.

Paso 1: Crear una subclase de UIView

Crea un nuevo archivo Swift (ej. CustomCardView.swift) y define tu clase:

import UIKit

class CustomCardView: UIView {

    // Propiedades personalizadas para nuestra vista
    var title: String = "Título por defecto" {
        didSet { // Cuando el título cambia, actualizamos la UI
            titleLabel.text = title
        }
    }

    var subtitle: String = "Subtítulo por defecto" {
        didSet {
            subtitleLabel.text = subtitle
        }
    }

    var cardBackgroundColor: UIColor = .systemBlue {
        didSet {
            backgroundColor = cardBackgroundColor
        }
    }

    // Subviews internas
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .headline)
        label.textColor = .white
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false // ¡Importante para Auto Layout!
        return label
    }()

    private let subtitleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .subheadline)
        label.textColor = .white.withAlphaComponent(0.8)
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    // MARK: - Initializers

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    // MARK: - Setup Methods

    private func setupView() {
        // Configuración inicial de la vista
        layer.cornerRadius = 12
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.2
        layer.shadowOffset = CGSize(width: 0, height: 4)
        layer.shadowRadius = 8

        backgroundColor = cardBackgroundColor

        // Añadir subviews
        addSubview(titleLabel)
        addSubview(subtitleLabel)

        // Configurar Auto Layout
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),

            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
            subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
            subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20)
        ])

        // Asignar valores iniciales a las etiquetas
        titleLabel.text = title
        subtitleLabel.text = subtitle
    }

    // MARK: - Layout Cycle Overrides

    override func layoutSubviews() {
        super.layoutSubviews()
        // Aquí podemos hacer ajustes de layout basados en el tamaño final de la vista
        // Por ejemplo, ajustar la sombra si la forma de la vista puede cambiar dinámicamente
        // La ruta de la sombra debería coincidir con el `cornerRadius`
        layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
    }
}

Paso 2: Entendiendo los initializers

Cuando creas una UIView personalizada, es crucial entender los inicializadores:

  • override init(frame: CGRect): Este se usa cuando creas la vista programáticamente (sin Storyboard/XIB).
  • required init?(coder: NSCoder): Este se usa cuando la vista se carga desde un Storyboard o un archivo XIB. El @required significa que todas las subclases deben implementar este inicializador.

En ambos, llamamos a super.init(...) para asegurarnos de que la superclase (UIView) se inicialice correctamente y luego llamamos a setupView() para configurar nuestra vista.

Paso 3: Configurando subviews y Auto Layout

En setupView(), realizamos varias tareas:

  1. Estilo de la vista contenedora: layer.cornerRadius, layer.shadow para darle un aspecto de tarjeta.
  2. Añadir subviews: Usamos addSubview() para añadir las UILabel a nuestra CustomCardView.
  3. Auto Layout: Configuramos las restricciones para posicionar titleLabel y subtitleLabel. ¡Recuerda translatesAutoresizingMaskIntoConstraints = false para cualquier vista que quieras controlar con Auto Layout!
📌 Nota: Los `didSet` de las propiedades `title`, `subtitle` y `cardBackgroundColor` aseguran que la UI se actualice automáticamente cuando se modifican estas propiedades después de la inicialización.

Paso 4: Usando la CustomCardView

Ahora puedes usar tu CustomCardView en cualquier UIViewController o incluso dentro de otra UIView:

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemGray6

        let cardView = CustomCardView()
        cardView.title = "Bienvenido al Tutorial 👋"
        cardView.subtitle = "Aprende a crear vistas personalizadas."
        cardView.cardBackgroundColor = .systemTeal
        cardView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(cardView)

        NSLayoutConstraint.activate([
            cardView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            cardView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            cardView.widthAnchor.constraint(equalToConstant: 300),
            cardView.heightAnchor.constraint(equalToConstant: 180)
        ])

        // Otra tarjeta con diferentes propiedades
        let anotherCardView = CustomCardView()
        anotherCardView.title = "¡Componentes Reusables! ✨"
        anotherCardView.subtitle = "Acelera tu desarrollo."
        anotherCardView.cardBackgroundColor = .systemIndigo
        anotherCardView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(anotherCardView)

        NSLayoutConstraint.activate([
            anotherCardView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            anotherCardView.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20),
            anotherCardView.widthAnchor.constraint(equalToConstant: 300),
            anotherCardView.heightAnchor.constraint(equalToConstant: 120)
        ])
    }
}
ViewController Tarjeta Principal Contenido destacado y detalles importantes. Tarjeta Secundaria Información de apoyo y estadísticas rápidas.

🎨 Dibujo Personalizado con Core Graphics (draw(rect:))

Cuando los componentes estándar y las subviews no son suficientes, puedes tomar el control total del dibujo utilizando Core Graphics dentro del método draw(rect:).

Entendiendo draw(rect:)

  • El método draw(rect:) es llamado por el sistema cuando la vista necesita ser dibujada o redibujada.
  • Solo debes implementar draw(rect:) si necesitas hacer un dibujo completamente personalizado, como formas, degradados o gráficos. Para la mayoría de los casos de UI con texto e imágenes, usar UILabel, UIImageView y subviews es más eficiente.
  • Para forzar un redibujo, llama a setNeedsDisplay(). El sistema agrupará las solicitudes y llamará a draw(rect:) en el próximo ciclo de dibujo.

Vamos a crear una CustomProgressView que muestre un progreso circular.

import UIKit

class CustomProgressView: UIView {

    var progress: CGFloat = 0.0 {
        didSet {
            // Aseguramos que el progreso esté entre 0 y 1
            progress = max(0, min(1, progress))
            // Forzamos un redibujo de la vista
            setNeedsDisplay()
        }
    }

    var progressBarColor: UIColor = .systemGreen
    var trackColor: UIColor = .systemGray5
    var lineWidth: CGFloat = 10.0
    var showPercentage: Bool = true

    // MARK: - Initializers

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .clear // Importante para que el fondo sea transparente
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        backgroundColor = .clear
    }

    // MARK: - Drawing

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        guard let context = UIGraphicsGetCurrentContext() else { return }

        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = (min(bounds.width, bounds.height) / 2) - (lineWidth / 2)
        let startAngle: CGFloat = -.pi / 2 // Empieza arriba
        let endAngle: CGFloat = (2 * .pi * progress) - (.pi / 2)

        // 1. Dibujar el 'track' (fondo de la barra de progreso)
        context.addArc(
            center: center,
            radius: radius,
            startAngle: 0,
            endAngle: 2 * .pi,
            clockwise: false
        )
        context.setStrokeColor(trackColor.cgColor)
        context.setLineWidth(lineWidth)
        context.setLineCap(.round) // Opcional: puntas redondeadas
        context.strokePath()

        // 2. Dibujar la barra de progreso
        context.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: false
        )
        context.setStrokeColor(progressBarColor.cgColor)
        context.setLineWidth(lineWidth)
        context.setLineCap(.round)
        context.strokePath()

        // 3. Dibujar el porcentaje (opcional)
        if showPercentage {
            let percentageText = "\(Int(progress * 100))%"
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 20, weight: .bold),
                .foregroundColor: UIColor.label // Color adaptable al modo oscuro
            ]
            let attributedText = NSAttributedString(string: percentageText, attributes: attributes)
            let textSize = attributedText.size()
            let textRect = CGRect(
                x: center.x - textSize.width / 2,
                y: center.y - textSize.height / 2,
                width: textSize.width,
                height: textSize.height
            )
            attributedText.draw(in: textRect)
        }
    }
}

Usando CustomProgressView

import UIKit

class DrawingViewController: UIViewController {

    private let progressView = CustomProgressView()
    private let slider = UISlider()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = "Progreso Circular"

        setupProgressView()
        setupSlider()
    }

    private func setupProgressView() {
        progressView.translatesAutoresizingMaskIntoConstraints = false
        progressView.progressBarColor = .systemPurple
        progressView.trackColor = .systemGray4
        progressView.lineWidth = 15
        progressView.progress = 0.25 // Progreso inicial
        view.addSubview(progressView)

        NSLayoutConstraint.activate([
            progressView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            progressView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -50),
            progressView.widthAnchor.constraint(equalToConstant: 200),
            progressView.heightAnchor.constraint(equalToConstant: 200)
        ])
    }

    private func setupSlider() {
        slider.translatesAutoresizingMaskIntoConstraints = false
        slider.minimumValue = 0.0
        slider.maximumValue = 1.0
        slider.value = Float(progressView.progress)
        slider.addTarget(self, action: #selector(sliderValueChanged(_:)), for: .valueChanged)
        view.addSubview(slider)

        NSLayoutConstraint.activate([
            slider.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: 40),
            slider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40),
            slider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40)
        ])
    }

    @objc private func sliderValueChanged(_ sender: UISlider) {
        progressView.progress = CGFloat(sender.value)
    }
}
25% Progreso del sistema
🔥 Importante: Evita realizar cálculos o operaciones complejas dentro de `draw(rect:)` si no son estrictamente necesarias para el dibujo. Este método puede ser llamado frecuentemente, y un código ineficiente aquí puede causar problemas de rendimiento.

⚡ Manejo de Eventos: De UIView a UIControl

Cuando tu Custom View necesita responder a interacciones de usuario de una manera más estructurada, o si quieres que se comporte como un control estándar de UIKit (como un botón o un slider), subclasar UIControl es la solución.

UIControl introduce el mecanismo target-action, una forma potente de comunicar eventos desde el control a otros objetos.

Vamos a transformar nuestra CustomCardView en una TappableCardControl que notifica cuando es tocada.

Paso 1: Subclase UIControl

import UIKit

class TappableCardControl: UIControl {

    var title: String = "" {
        didSet { titleLabel.text = title }
    }
    var subtitle: String = "" {
        didSet { subtitleLabel.text = subtitle }
    }
    var cardBackgroundColor: UIColor = .systemBlue {
        didSet { backgroundColor = cardBackgroundColor }
    }

    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .headline)
        label.textColor = .white
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private let subtitleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .subheadline)
        label.textColor = .white.withAlphaComponent(0.8)
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    // MARK: - Initializers

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    // MARK: - Setup Methods

    private func setupView() {
        layer.cornerRadius = 12
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.2
        layer.shadowOffset = CGSize(width: 0, height: 4)
        layer.shadowRadius = 8
        backgroundColor = cardBackgroundColor

        addSubview(titleLabel)
        addSubview(subtitleLabel)

        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),

            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
            subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
            subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20)
        ])

        titleLabel.text = title
        subtitleLabel.text = subtitle
    }

    // MARK: - Handling Touch Events (Overrides de UIControl)

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        // Animación de resaltado al tocar
        UIView.animate(withDuration: 0.1) { [weak self] in
            self?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
            self?.alpha = 0.7
        }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        // Restaurar estado al soltar
        UIView.animate(withDuration: 0.1) { [weak self] in
            self?.transform = .identity
            self?.alpha = 1.0
        }
        // Enviar la acción principal del control
        sendActions(for: .touchUpInside)
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        // Restaurar estado si el toque es cancelado
        UIView.animate(withDuration: 0.1) { [weak self] in
            self?.transform = .identity
            self?.alpha = 1.0
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
    }
}

Paso 2: Implementando el target-action

UIControl nos da los métodos sendActions(for: .touchUpInside) (y otros UIControl.Event) para notificar a los 'targets' que se han añadido. Al sobrescribir touchesEnded, disparamos el evento .touchUpInside.

Paso 3: Usando la TappableCardControl

Ahora podemos añadir un 'target' y una 'action' a nuestra tarjeta de la misma manera que lo haríamos con un UIButton.

import UIKit

class ControlViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()+
        view.backgroundColor = .systemGray6
        title = "Controles Personalizados"

        let cardControl = TappableCardControl()
        cardControl.title = "¡Soy un Control! 👆"
        cardControl.subtitle = "Tócame para ver la acción."
        cardControl.cardBackgroundColor = .systemOrange
        cardControl.translatesAutoresizingMaskIntoConstraints = false

        // Añadir el target y la acción
        cardControl.addTarget(self, action: #selector(cardTapped), for: .touchUpInside)

        view.addSubview(cardControl)

        NSLayoutConstraint.activate([
            cardControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            cardControl.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            cardControl.widthAnchor.constraint(equalToConstant: 320),
            cardControl.heightAnchor.constraint(equalToConstant: 150)
        ])
    }

    @objc private func cardTapped() {
        let alert = UIAlertController(title: "Tarjeta Tocada", message: "Has interactuado con el control personalizado.", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        present(alert, animated: true, completion: nil)
    }
}
📌 Nota: Los métodos `touchesBegan`, `touchesEnded`, `touchesMoved` y `touchesCancelled` te dan un control granular sobre las interacciones táctiles. Para interacciones más complejas, considera usar `UIGestureRecognizer` en lugar de sobrescribir estos métodos directamente, aunque para `UIControl` es común hacerlo para manejar los estados y animaciones de resaltado.

🌐 Haciendo tu Custom View Configurable y Extensible

Una buena Custom View es fácil de usar y personalizar. Aquí te mostramos cómo mejorar la flexibilidad de tus componentes.

1. Propiedades IBDesignable y IBInspectable

Para poder previsualizar tu Custom View en Interface Builder y editar sus propiedades directamente desde el panel de atributos, usa @IBDesignable y @IBInspectable.

  • @IBDesignable: Hace que tu vista sea renderizable en Interface Builder. Xcode compilará tu vista y la mostrará en el lienzo.
  • @IBInspectable: Expone una propiedad de tu vista al panel de atributos de Interface Builder, permitiéndote modificarla sin código.

Ejemplo en TappableCardControl.swift:

import UIKit

@IBDesignable // Hace que la vista sea renderizable en Interface Builder
class TappableCardControl: UIControl {

    @IBInspectable var title: String = "" {
        didSet { titleLabel.text = title }
    }
    @IBInspectable var subtitle: String = "" {
        didSet { subtitleLabel.text = subtitle }
    }
    @IBInspectable var cardBackgroundColor: UIColor = .systemBlue {
        didSet { backgroundColor = cardBackgroundColor }
    }
    @IBInspectable var cornerRadius: CGFloat = 12 {
        didSet { layer.cornerRadius = cornerRadius; setNeedsDisplay() }
    }
    @IBInspectable var shadowOpacity: Float = 0.2 {
        didSet { layer.shadowOpacity = shadowOpacity; setNeedsDisplay() }
    }
    @IBInspectable var shadowRadius: CGFloat = 8 {
        didSet { layer.shadowRadius = shadowRadius; setNeedsDisplay() }
    }

    // ... (resto del código de la clase, incluyendo subviews y setupView)

    override func layoutSubviews() {
        super.layoutSubviews()
        // Actualizamos shadowPath si cornerRadius o bounds cambian
        layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
    }
}

Con esto, al arrastrar un UIView genérico desde la librería de objetos a tu Storyboard y cambiar su clase a TappableCardControl, verás tu diseño personalizado y podrás ajustar title, subtitle, cornerRadius, etc., directamente en el Inspector de Atributos.

Main.storyboard — TappableCardControl Interface Builder Canvas Título de Tarjeta Subtítulo de ejemplo Acción ATTRIBUTES INSPECTOR Title Título de Tarjeta Subtitle Subtítulo de ejemplo Card Background Color White Corner Radius 12.0 Shadow Opacity 0.1 Shadow Radius 6.0 TAPPABLE CARD CONTROL

2. Protocolos y Delegados para Comunicación 💪

Para una comunicación más compleja o específica que el target-action (que es más genérico para eventos de control), los protocolos y delegados son la mejor opción. Permiten que tu Custom View comunique eventos o datos a un objeto delegado, manteniendo la vista independiente y reutable.

Ejemplo: Imagina una UserAvatarView que muestra la imagen de un usuario y permite tocarla para ver su perfil. La vista en sí no debería saber cómo mostrar el perfil, solo que se tocó. El delegado se encargaría de eso.

import UIKit

// 1. Definir el protocolo del delegado
protocol UserAvatarViewDelegate: AnyObject {
    func userAvatarViewDidTapAvatar(_ avatarView: UserAvatarView, forUserID userID: String)
}

class UserAvatarView: UIView {

    // 2. Declarar una propiedad `delegate` débil
    weak var delegate: UserAvatarViewDelegate? // Usar `weak` para evitar ciclos de referencia

    private let profileImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()

    private let usernameLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .caption1)
        label.textColor = .label
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    var userID: String? // Propiedad para identificar al usuario

    // MARK: - Initializers

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private func setupView() {
        addSubview(profileImageView)
        addSubview(usernameLabel)

        NSLayoutConstraint.activate([
            profileImageView.topAnchor.constraint(equalTo: topAnchor),
            profileImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            profileImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
            profileImageView.heightAnchor.constraint(equalTo: profileImageView.widthAnchor),

            usernameLabel.topAnchor.constraint(equalTo: profileImageView.bottomAnchor, constant: 4),
            usernameLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            usernameLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            usernameLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        // Añadir reconocimiento de gestos para la interactividad
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        addGestureRecognizer(tapGesture)
        isUserInteractionEnabled = true // Asegúrate de que la vista puede recibir toques
    }

    // Actualizar la imagen y el nombre
    func configure(withImage image: UIImage?, username: String, userID: String) {
        profileImageView.image = image
        usernameLabel.text = username
        self.userID = userID
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        // Aseguramos que la imagen sea circular
        profileImageView.layer.cornerRadius = profileImageView.bounds.height / 2
    }

    @objc private func handleTap() {
        guard let userID = userID else { return }
        // 3. Notificar al delegado
        delegate?.userAvatarViewDidTapAvatar(self, forUserID: userID)
    }
}

Implementando el Delegado en un UIViewController:

import UIKit

class ProfileViewController: UIViewController, UserAvatarViewDelegate {

    private let avatarView = UserAvatarView()
    private let statusLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = "Perfil de Usuario"

        setupAvatarView()
        setupStatusLabel()
    }

    private func setupAvatarView() {
        avatarView.translatesAutoresizingMaskIntoConstraints = false
        avatarView.delegate = self // ¡Importante: Asignar el delegado!
        avatarView.configure(withImage: UIImage(systemName: "person.circle.fill"), username: "Jane Doe", userID: "user123")
        view.addSubview(avatarView)

        NSLayoutConstraint.activate([
            avatarView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            avatarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 50),
            avatarView.widthAnchor.constraint(equalToConstant: 100),
            avatarView.heightAnchor.constraint(equalToConstant: 120)
        ])
    }

    private func setupStatusLabel() {
        statusLabel.translatesAutoresizingMaskIntoConstraints = false
        statusLabel.text = "Toca el avatar para ver el perfil completo."
        statusLabel.textColor = .secondaryLabel
        statusLabel.textAlignment = .center
        view.addSubview(statusLabel)

        NSLayoutConstraint.activate([
            statusLabel.topAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 20),
            statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }

    // MARK: - UserAvatarViewDelegate

    func userAvatarViewDidTapAvatar(_ avatarView: UserAvatarView, forUserID userID: String) {
        print("Avatar tocado para el usuario con ID: \(userID)")
        let alert = UIAlertController(title: "Perfil de Usuario", message: "Cargando perfil de: \(userID)", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        present(alert, animated: true, completion: nil)
    }
}

Este patrón es fundamental para mantener la separación de responsabilidades y la reusabilidad de tus componentes UI. La UserAvatarView es ahora un componente de UI genérico que puede ser usado en cualquier lugar, y el ProfileViewController decide qué hacer cuando el avatar es tocado.


💡 Consideraciones Avanzadas y Mejores Prácticas

Accesibilidad (Accessibility) ✅

No olvides la accesibilidad. UIKit facilita mucho la creación de interfaces accesibles para todos los usuarios, incluyendo aquellos con discapacidades. Para Custom Views:

  • Asegúrate de que las propiedades isAccessibilityElement, accessibilityLabel, accessibilityHint, accessibilityValue y accessibilityTraits estén configuradas correctamente.
  • Para Custom Controls, asegúrate de que VoiceOver pueda interactuar con ellos, simulando los eventos que el usuario de pantalla haría.
// En tu TappableCardControl
override func setupView() {
    // ... (configuración existente)

    // Configuración de accesibilidad
    isAccessibilityElement = true
    accessibilityLabel = "Tarjeta pulsable con título: \(title)"
    accessibilityHint = "Doble toque para activar esta tarjeta."
    accessibilityTraits = .button // O el rasgo más apropiado
}

Internacionalización (Localization) 🌍

Si tu aplicación va a ser usada en diferentes idiomas, asegúrate de que todos los textos en tus Custom Views sean localizables. Usa NSLocalizedString o cadenas de texto configurables a través de propiedades.

// Ejemplo de texto localizable
usernameLabel.text = NSLocalizedString("username_default_text", comment: "Default text for username label")

Rendimiento 🏎️

  • Evita redibujos innecesarios: Implementa draw(rect:) solo cuando sea estrictamente necesario. Si solo estás moviendo subviews, usa Auto Layout o manipula frame directamente.
  • Cachea cálculos costosos: Si tienes cálculos complejos para el dibujo, cacheados y solo recalcularlos cuando los parámetros relevantes cambien.
  • opaque = true: Si tu vista es completamente opaca (no tiene transparencias y cubre todo su fondo), establece isOpaque = true para que UIKit optimice el dibujo, evitando dibujar contenido detrás de ella.
  • shouldRasterize: Para vistas complejas que no cambian a menudo pero son costosas de renderizar, puedes usar layer.shouldRasterize = true con layer.rasterizationScale = UIScreen.main.scale. Esto rasteriza la vista en una imagen y la cachea, lo que puede mejorar el rendimiento en animaciones, pero puede degradar la calidad si la vista se escala.

Pruebas Unitarias (Unit Testing) 🧪

Diseña tus Custom Views de manera que sean fáciles de testear. Separa la lógica de presentación de la lógica de negocio. Puedes probar:

  • Que las propiedades configuran correctamente la UI.
  • Que los eventos se disparan cuando deben.
  • Que el layout se adapta a diferentes tamaños.

🎁 Envolviendo Componentes Existentes

A veces, en lugar de construir una vista desde cero, solo necesitas envolver y mejorar un componente existente de UIKit. Esto es ideal para aplicar un estilo consistente o añadir funcionalidades específicas sin reescribir todo.

Ejemplo: Un CustomTextField con icono y validación.

Podrías crear una UIView que contenga un UITextField, un UIImageView para el icono y un UILabel para mensajes de error. Luego, expones propiedades y métodos para interactuar con el UITextField interno.

import UIKit

class CustomTextField: UIView {

    let textField: UITextField = {
        let tf = UITextField()
        tf.borderStyle = .none
        tf.translatesAutoresizingMaskIntoConstraints = false
        return tf
    }()

    let iconImageView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFit
        iv.translatesAutoresizingMaskIntoConstraints = false
        iv.tintColor = .systemGray
        return iv
    }()

    let errorLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.preferredFont(forTextStyle: .caption2)
        label.textColor = .systemRed
        label.isHidden = true
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    @IBInspectable var placeholder: String? {
        didSet { textField.placeholder = placeholder }
    }

    @IBInspectable var icon: UIImage? {
        didSet { iconImageView.image = icon }
    }

    // ... inicializadores y setupView

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private func setupView() {
        backgroundColor = .systemBackground
        layer.cornerRadius = 8
        layer.borderWidth = 1
        layer.borderColor = UIColor.systemGray4.cgColor

        addSubview(iconImageView)
        addSubview(textField)
        addSubview(errorLabel)

        NSLayoutConstraint.activate([
            iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            iconImageView.centerYAnchor.constraint(equalTo: textField.centerYAnchor),
            iconImageView.widthAnchor.constraint(equalToConstant: 24),
            iconImageView.heightAnchor.constraint(equalToConstant: 24),

            textField.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8),
            textField.topAnchor.constraint(equalTo: topAnchor, constant: 10),
            textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            textField.heightAnchor.constraint(equalToConstant: 30),

            errorLabel.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 4),
            errorLabel.leadingAnchor.constraint(equalTo: textField.leadingAnchor),
            errorLabel.trailingAnchor.constraint(equalTo: textField.trailingAnchor),
            errorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10)
        ])
    }

    func showError(_ message: String?) {
        errorLabel.text = message
        errorLabel.isHidden = (message == nil)
        layer.borderColor = (message == nil) ? UIColor.systemGray4.cgColor : UIColor.systemRed.cgColor
    }

    func hideError() {
        showError(nil)
    }
}

Este patrón es extremadamente útil para crear un sistema de diseño propio y asegurar que todos los UITextField (o cualquier otro control) en tu aplicación tengan la misma apariencia y comportamiento. Puedes añadir más propiedades para controlar el tipo de teclado, el texto seguro, etc., simplemente delegando al UITextField interno.


🔚 Conclusión

¡Felicidades! Has recorrido un camino completo para dominar la creación de Custom Views y Controles en iOS con Swift y UIKit. Desde los fundamentos de UIView y UIControl hasta técnicas avanzadas de dibujo con Core Graphics, manejo de eventos, y la mejora de la reusabilidad con IBDesignable/IBInspectable y delegados.

La capacidad de construir tus propios componentes te da una libertad de diseño sin precedentes y la habilidad de crear aplicaciones verdaderamente únicas y eficientes. Recuerda siempre priorizar la reusabilidad, la mantenibilidad y la accesibilidad en tus diseños. ¡Ahora estás listo para crear interfaces sorprendentes!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!