Azimjon Abdurasulov

SnapKit 13: Qayta Ishlatiluvchi Komponentlar — BaseView Arxitekturasi, Custom Controls va Design System

🧩 SnapKit — Zero to Hero

SnapKit 13: Qayta Ishlatiluvchi Komponentlar — BaseView Arxitekturasi, Custom Controls va Design System

Holat: Ushbu darslik 2026-yil oktabr holatiga ko'ra eng zamonaviy iOS dasturlash amaliyotlari (Swift 6, SnapKit 5.7+) va zamonaviy arxitektura tamoyillari asosida tayyorlangan. Reusable (qayta ishlatiluvchi) komponentlar yaratish yirik loyihalarning eng muhim qismidir.

Mundarija

  1. BaseView Pattern (Eng muhim arxitektura)
  2. Komponent 1: CustomTextField (Floating Label)
  3. Komponent 2: PrimaryButton (Loading holati)
  4. Komponent 3: BadgeView
  5. Komponent 4: RatingView (Yulduzchalar)
  6. Komponent 5: AvatarView
  7. Komponent 6: CardView
  8. Komponent 7: EmptyStateView
  9. Design System Tushunchasi
  10. Amaliy Loyiha: Komponentlarni Birlashtirish
  11. Xulosa
  12. Manbalar

1. BaseView Pattern (Eng muhim arxitektura) {#baseview}

Ilovalarni ishlab chiqishda UI komponentlar yaratish qoidalari aniq bir standartga bo'ysunishi kerak. Agar har bir dasturchi view larni o'zicha sozlasa, kod tezda tushunarsiz va parokanda bo'lib ketadi. Buning oldini olish uchun BaseView Pattern ishlatiladi.

Senior eslatmasi: Barcha custom view'laringiz aynan bitta poydevordan (BaseView) meros olishi kerak. Bu orqali siz hayot siklini (lifecycle) standartlaysiz: doim birinchi subviews qo'shiladi, keyin constraints va oxirida bindings (harakatlar/reaksiyalar).

import UIKit
import SnapKit

@MainActor
open class BaseView: UIView {
    
    public override init(frame: CGRect) {
        super.init(frame: frame)
        setupSubviews()
        setupConstraints()
        setupBindings()
    }
    
    public required init?(coder: NSCoder) {
        fatalError("init(coder:) ishlari Interface Builder uchun. Biz faqat kodda ishlaymiz!")
    }
    
    /// View'larni qo'shish uchun (addSubview)
    open func setupSubviews() {}
    
    /// SnapKit constraint'larini o'rnatish uchun
    open func setupConstraints() {}
    
    /// Target-action, gesture recognizers va callback'larni sozlash uchun
    open func setupBindings() {}
}

Ushbu arxitekturaning afzalligi shundaki, keyingi barcha komponentlarimiz faqat shu 3 ta metodni override qiladi, bu kod o'qiluvchanligini 10 barobar oshiradi.


2. Komponent 1: CustomTextField (Floating Label) {#textfield}

Zamonaviy dizaynlarda, foydalanuvchi yozishni boshlaganda placeholder tepaga ko'tariluvchi (floating) matn maydonlari juda mashhur. Shuningdek, xato yuz bergan holat (Error state) ham bo'lishi kerak.

Normal Holat:
+-----------------------------------+
|  [Icon]   Ismingizni kiriting     |
+-----------------------------------+

Yozilayotganda (Floating):
  Ismingiz
+-----------------------------------+
|  [Icon]   Azimjon|                |
+-----------------------------------+
import UIKit
import SnapKit

@MainActor
final class CustomTextField: BaseView {
    
    private let iconImageView = UIImageView()
    private let textField = UITextField()
    private let floatingLabel = UILabel()
    private let errorLabel = UILabel()
    private let containerView = UIView()
    
    private var topConstraint: Constraint?
    
    override func setupSubviews() {
        addSubview(containerView)
        addSubview(floatingLabel)
        addSubview(errorLabel)
        
        containerView.addSubview(iconImageView)
        containerView.addSubview(textField)
        
        // Stil sozlamalari
        containerView.layer.borderWidth = 1
        containerView.layer.borderColor = UIColor.systemGray4.cgColor
        containerView.layer.cornerRadius = 8
        
        floatingLabel.font = .systemFont(ofSize: 12)
        floatingLabel.textColor = .systemGray
        floatingLabel.alpha = 0
        
        errorLabel.font = .systemFont(ofSize: 12)
        errorLabel.textColor = .systemRed
        errorLabel.isHidden = true
        
        textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
        textField.addTarget(self, action: #selector(editingDidBegin), for: .editingDidBegin)
        textField.addTarget(self, action: #selector(editingDidEnd), for: .editingDidEnd)
    }
    
    override func setupConstraints() {
        containerView.snp.makeConstraints { make in
            make.top.equalToSuperview().offset(16)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(48)
        }
        
        iconImageView.snp.makeConstraints { make in
            make.leading.equalToSuperview().offset(12)
            make.centerY.equalToSuperview()
            make.size.equalTo(20)
        }
        
        textField.snp.makeConstraints { make in
            make.leading.equalTo(iconImageView.snp.trailing).offset(8)
            make.trailing.equalToSuperview().offset(-12)
            make.centerY.equalToSuperview()
        }
        
        floatingLabel.snp.makeConstraints { make in
            make.leading.equalTo(containerView.snp.leading).offset(4)
            // Boshlang'ich holatda container markazida, float bo'lganda tepaga chiqadi
            self.topConstraint = make.centerY.equalTo(containerView.snp.centerY).constraint
        }
        
        errorLabel.snp.makeConstraints { make in
            make.top.equalTo(containerView.snp.bottom).offset(4)
            make.leading.trailing.bottom.equalToSuperview()
        }
    }
    
    func configure(placeholder: String, icon: UIImage?) {
        textField.placeholder = placeholder
        floatingLabel.text = placeholder
        iconImageView.image = icon
        iconImageView.isHidden = icon == nil
        
        if icon == nil {
            textField.snp.remakeConstraints { make in
                make.leading.equalToSuperview().offset(12)
                make.trailing.equalToSuperview().offset(-12)
                make.centerY.equalToSuperview()
            }
        }
    }
    
    func setError(_ message: String?) {
        if let message = message {
            errorLabel.text = message
            errorLabel.isHidden = false
            containerView.layer.borderColor = UIColor.systemRed.cgColor
        } else {
            errorLabel.isHidden = true
            containerView.layer.borderColor = textField.isEditing ? UIColor.systemBlue.cgColor : UIColor.systemGray4.cgColor
        }
    }
    
    @objc private func editingDidBegin() {
        containerView.layer.borderColor = UIColor.systemBlue.cgColor
        animateFloatingLabel(isFloating: true)
    }
    
    @objc private func editingDidEnd() {
        containerView.layer.borderColor = UIColor.systemGray4.cgColor
        if textField.text?.isEmpty == true {
            animateFloatingLabel(isFloating: false)
        }
    }
    
    @objc private func textDidChange() {
        setError(nil) // Yozganda xatoni tozalash
    }
    
    private func animateFloatingLabel(isFloating: Bool) {
        UIView.animate(withDuration: 0.2) {
            if isFloating {
                self.topConstraint?.update(inset: 32)
                self.floatingLabel.alpha = 1
                self.textField.placeholder = ""
            } else {
                self.topConstraint?.update(inset: 0)
                self.floatingLabel.alpha = 0
                self.textField.placeholder = self.floatingLabel.text
            }
            self.layoutIfNeeded()
        }
    }
}

3. Komponent 2: PrimaryButton (Loading holati) {#button}

Asosiy tugmalar tarmoq so'rovlari vaqtida "Loading" holatiga o'tishi va foydalanuvchiga jarayon ketayotganini ko'rsatishi zarur.

import UIKit
import SnapKit

@MainActor
final class PrimaryButton: BaseView {
    
    private let titleLabel = UILabel()
    private let iconImageView = UIImageView()
    private let activityIndicator = UIActivityIndicatorView(style: .medium)
    private let stackView = UIStackView()
    
    var tapAction: (() -> Void)?
    
    override func setupSubviews() {
        backgroundColor = .systemBlue
        layer.cornerRadius = 12
        
        addSubview(stackView)
        addSubview(activityIndicator)
        
        stackView.axis = .horizontal
        stackView.spacing = 8
        stackView.alignment = .center
        
        stackView.addArrangedSubview(iconImageView)
        stackView.addArrangedSubview(titleLabel)
        
        titleLabel.font = .boldSystemFont(ofSize: 16)
        titleLabel.textColor = .white
        iconImageView.tintColor = .white
        activityIndicator.color = .white
        activityIndicator.hidesWhenStopped = true
        
        // Touch hodisalari uchun tap gesture o'rniga UIControl kabi ishlashini simulyatsiya qilamiz
        let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        addGestureRecognizer(tap)
        isUserInteractionEnabled = true
    }
    
    override func setupConstraints() {
        snp.makeConstraints { make in
            make.height.equalTo(50)
        }
        
        stackView.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
        
        iconImageView.snp.makeConstraints { make in
            make.size.equalTo(20)
        }
        
        activityIndicator.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }
    
    func configure(title: String, icon: UIImage? = nil) {
        titleLabel.text = title
        iconImageView.image = icon
        iconImageView.isHidden = icon == nil
    }
    
    func setLoading(_ isLoading: Bool) {
        isUserInteractionEnabled = !isLoading
        alpha = isLoading ? 0.7 : 1.0
        
        if isLoading {
            stackView.isHidden = true
            activityIndicator.startAnimating()
        } else {
            stackView.isHidden = false
            activityIndicator.stopAnimating()
        }
    }
    
    @objc private func handleTap() {
        // Kichik click animatsiyasi
        UIView.animate(withDuration: 0.1, animations: {
            self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
        }) { _ in
            UIView.animate(withDuration: 0.1) {
                self.transform = .identity
            }
            self.tapAction?()
        }
    }
}

4. Komponent 3: BadgeView {#badge}

Badge odatda xabarnomalar sonini ko'rsatish uchun ishlatiladi. Hajmi matn uzunligiga moslashuvchan bo'lishi kerak.

import UIKit
import SnapKit

@MainActor
final class BadgeView: BaseView {
    
    private let textLabel = UILabel()
    
    override func setupSubviews() {
        backgroundColor = .systemRed
        addSubview(textLabel)
        
        textLabel.textColor = .white
        textLabel.font = .systemFont(ofSize: 12, weight: .bold)
        textLabel.textAlignment = .center
    }
    
    override func setupConstraints() {
        textLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(6)
            make.top.bottom.equalToSuperview().inset(2)
        }
    }
    
    func setNumber(_ count: Int) {
        if count <= 0 {
            isHidden = true
        } else {
            isHidden = false
            textLabel.text = count > 99 ? "99+" : "\(count)"
        }
        setNeedsLayout()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        // Aylana yoki capsule shakliga o'tkazish
        let radius = bounds.height / 2
        layer.cornerRadius = radius
    }
}

5. Komponent 4: RatingView (Yulduzchalar) {#rating}

import UIKit
import SnapKit

@MainActor
final class RatingView: BaseView {
    
    private let stackView = UIStackView()
    private var starButtons: [UIButton] = []
    
    var didChangeRating: ((Int) -> Void)?
    var currentRating: Int = 0
    
    override func setupSubviews() {
        addSubview(stackView)
        stackView.axis = .horizontal
        stackView.spacing = 4
        stackView.distribution = .fillEqually
        
        for i in 0..<5 {
            let btn = UIButton()
            btn.setImage(UIImage(systemName: "star"), for: .normal)
            btn.setImage(UIImage(systemName: "star.fill"), for: .selected)
            btn.tintColor = .systemYellow
            btn.tag = i + 1
            btn.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside)
            stackView.addArrangedSubview(btn)
            starButtons.append(btn)
        }
    }
    
    override func setupConstraints() {
        stackView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        for btn in starButtons {
            btn.snp.makeConstraints { make in
                make.size.equalTo(30)
            }
        }
    }
    
    @objc private func starTapped(_ sender: UIButton) {
        let rating = sender.tag
        setRating(rating)
        didChangeRating?(rating)
    }
    
    func setRating(_ rating: Int) {
        currentRating = rating
        for (index, button) in starButtons.enumerated() {
            button.isSelected = index < rating
        }
    }
}

6. Komponent 5: AvatarView {#avatar}

Avatar komponenti onlayn indikatori va rasm bo'lmaganda initials (ismining bosh harflari) ni o'z ichiga oladi.

import UIKit
import SnapKit

@MainActor
final class AvatarView: BaseView {
    
    private let imageView = UIImageView()
    private let initialsLabel = UILabel()
    private let onlineIndicator = UIView()
    
    override func setupSubviews() {
        backgroundColor = .systemGray5
        
        addSubview(initialsLabel)
        addSubview(imageView)
        addSubview(onlineIndicator)
        
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        
        initialsLabel.textColor = .systemGray
        initialsLabel.font = .systemFont(ofSize: 20, weight: .bold)
        initialsLabel.textAlignment = .center
        
        onlineIndicator.backgroundColor = .systemGreen
        onlineIndicator.layer.borderColor = UIColor.white.cgColor
        onlineIndicator.layer.borderWidth = 2
    }
    
    override func setupConstraints() {
        imageView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        initialsLabel.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
        
        onlineIndicator.snp.makeConstraints { make in
            make.size.equalTo(14)
            make.bottom.trailing.equalToSuperview()
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        layer.cornerRadius = bounds.height / 2
        imageView.layer.cornerRadius = bounds.height / 2
        onlineIndicator.layer.cornerRadius = onlineIndicator.bounds.height / 2
    }
    
    func configure(image: UIImage?, initials: String, isOnline: Bool) {
        if let img = image {
            imageView.image = img
            initialsLabel.isHidden = true
        } else {
            imageView.image = nil
            initialsLabel.text = initials
            initialsLabel.isHidden = false
        }
        onlineIndicator.isHidden = !isOnline
    }
}

7. Komponent 6: CardView {#card}

Boshqa ko'rinishlarni o'z ichida saqlovchi konteyner.

import UIKit
import SnapKit

@MainActor
final class CardView: BaseView {
    
    private let headerLabel = UILabel()
    private let contentView = UIView()
    private let containerStack = UIStackView()
    
    override func setupSubviews() {
        backgroundColor = .systemBackground
        layer.cornerRadius = 16
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.1
        layer.shadowOffset = CGSize(width: 0, height: 4)
        layer.shadowRadius = 8
        
        addSubview(containerStack)
        containerStack.axis = .vertical
        containerStack.spacing = 12
        
        headerLabel.font = .boldSystemFont(ofSize: 18)
        
        containerStack.addArrangedSubview(headerLabel)
        containerStack.addArrangedSubview(contentView)
    }
    
    override func setupConstraints() {
        containerStack.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(16)
        }
    }
    
    func setHeader(title: String) {
        headerLabel.text = title
    }
    
    func setContent(_ view: UIView) {
        contentView.subviews.forEach { $0.removeFromSuperview() }
        contentView.addSubview(view)
        
        view.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }
}

8. Komponent 7: EmptyStateView {#empty}

import UIKit
import SnapKit

@MainActor
final class EmptyStateView: BaseView {
    
    private let iconImageView = UIImageView()
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private let actionButton = PrimaryButton()
    
    override func setupSubviews() {
        addSubview(iconImageView)
        addSubview(titleLabel)
        addSubview(subtitleLabel)
        addSubview(actionButton)
        
        iconImageView.tintColor = .systemGray3
        iconImageView.contentMode = .scaleAspectFit
        
        titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
        titleLabel.textAlignment = .center
        
        subtitleLabel.font = .systemFont(ofSize: 14)
        subtitleLabel.textColor = .systemGray
        subtitleLabel.textAlignment = .center
        subtitleLabel.numberOfLines = 0
    }
    
    override func setupConstraints() {
        iconImageView.snp.makeConstraints { make in
            make.top.equalToSuperview().offset(24)
            make.centerX.equalToSuperview()
            make.size.equalTo(80)
        }
        
        titleLabel.snp.makeConstraints { make in
            make.top.equalTo(iconImageView.snp.bottom).offset(16)
            make.leading.trailing.equalToSuperview().inset(24)
        }
        
        subtitleLabel.snp.makeConstraints { make in
            make.top.equalTo(titleLabel.snp.bottom).offset(8)
            make.leading.trailing.equalToSuperview().inset(24)
        }
        
        actionButton.snp.makeConstraints { make in
            make.top.equalTo(subtitleLabel.snp.bottom).offset(24)
            make.leading.trailing.equalToSuperview().inset(40)
            make.bottom.equalToSuperview().offset(-24)
        }
    }
    
    func configure(icon: UIImage?, title: String, subtitle: String, buttonTitle: String, action: @escaping () -> Void) {
        iconImageView.image = icon
        titleLabel.text = title
        subtitleLabel.text = subtitle
        actionButton.configure(title: buttonTitle)
        actionButton.tapAction = action
    }
}

9. Design System Tushunchasi {#design-system}

Katta loyihalarda ranglar, o'lchamlar va shriftlarni to'g'ridan-to'g'ri 16, 24, .systemBlue deb yozish noto'g'ri. Biz Design Tokens (dizayn belgilar) dan foydalanishimiz kerak.

import UIKit

enum Spacing {
    static let xs: CGFloat = 4
    static let sm: CGFloat = 8
    static let md: CGFloat = 16
    static let lg: CGFloat = 24
    static let xl: CGFloat = 32
}

enum AppColors {
    static let primary = UIColor.systemBlue
    static let background = UIColor.systemGroupedBackground
    static let textMain = UIColor.label
    static let textSecondary = UIColor.secondaryLabel
}

// SnapKit'da ishlatish:
// make.top.equalToSuperview().offset(Spacing.md)
// view.backgroundColor = AppColors.background

Bu usul orqali barcha komponentlar bir xil "til" da gaplashadi. Bitta joydan dizaynni o'zgartirish butun ilovaga ta'sir qiladi.


10. Amaliy Loyiha: Komponentlarni Birlashtirish {#amaliy}

Keling, barcha yaratgan komponentlarimizni bitta Ekranga (Profilni tahrirlash) jamlaymiz.

import UIKit
import SnapKit

@MainActor
final class EditProfileViewController: UIViewController {
    
    private let scrollView = UIScrollView()
    private let contentView = UIView()
    
    private let avatarView = AvatarView()
    private let nameField = CustomTextField()
    private let emailField = CustomTextField()
    private let ratingView = RatingView()
    private let cardView = CardView()
    private let saveButton = PrimaryButton()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = AppColors.background
        title = "Profil"
        
        setupViews()
        setupConstraints()
        configureComponents()
    }
    
    private func setupViews() {
        view.addSubview(scrollView)
        scrollView.addSubview(contentView)
        
        contentView.addSubview(avatarView)
        contentView.addSubview(nameField)
        contentView.addSubview(emailField)
        contentView.addSubview(cardView)
        contentView.addSubview(saveButton)
        
        // RatingView ni Card ichiga joylashtiramiz
        let ratingContainer = UIView()
        ratingContainer.addSubview(ratingView)
        ratingView.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.top.bottom.equalToSuperview().inset(Spacing.md)
        }
        cardView.setContent(ratingContainer)
    }
    
    private func setupConstraints() {
        scrollView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        contentView.snp.makeConstraints { make in
            make.edges.width.equalToSuperview()
        }
        
        avatarView.snp.makeConstraints { make in
            make.top.equalToSuperview().offset(Spacing.xl)
            make.centerX.equalToSuperview()
            make.size.equalTo(100)
        }
        
        nameField.snp.makeConstraints { make in
            make.top.equalTo(avatarView.snp.bottom).offset(Spacing.xl)
            make.leading.trailing.equalToSuperview().inset(Spacing.md)
        }
        
        emailField.snp.makeConstraints { make in
            make.top.equalTo(nameField.snp.bottom).offset(Spacing.md)
            make.leading.trailing.equalToSuperview().inset(Spacing.md)
        }
        
        cardView.snp.makeConstraints { make in
            make.top.equalTo(emailField.snp.bottom).offset(Spacing.xl)
            make.leading.trailing.equalToSuperview().inset(Spacing.md)
        }
        
        saveButton.snp.makeConstraints { make in
            make.top.equalTo(cardView.snp.bottom).offset(Spacing.xl)
            make.leading.trailing.equalToSuperview().inset(Spacing.md)
            make.bottom.equalToSuperview().offset(-Spacing.xl) // ScrollView chegarasi
        }
    }
    
    private func configureComponents() {
        avatarView.configure(image: nil, initials: "AZ", isOnline: true)
        
        nameField.configure(placeholder: "Ismingiz", icon: UIImage(systemName: "person"))
        emailField.configure(placeholder: "Email manzilingiz", icon: UIImage(systemName: "envelope"))
        
        cardView.setHeader(title: "Foydalanuvchi darajasi")
        ratingView.setRating(4)
        
        saveButton.configure(title: "Saqlash")
        saveButton.tapAction = { [weak self] in
            self?.saveButton.setLoading(true)
            // Fake network request
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self?.saveButton.setLoading(false)
            }
        }
    }
}

Xulosa {#xulosa}

Bu darsda eng kuchli va haqiqiy kompaniyalarda ishlatiluvchi BaseView arxitekturasi va qayta ishlatiluvchi (reusable) komponentlarni ko'rib chiqdik. SnapKit bilan UI yaratishda Custom Components qilish quyidagi afzalliklarni beradi:

  1. Kod takrorlanmaydi (DRY - Don't Repeat Yourself).
  2. UI qismlarini mustaqil test qilish imkoniyati.
  3. Yangi ekranlar yaratish xuddi Lego o'ynashdek oson bo'lib qoladi.

Manbalar {#manbalar}

Markdown manba

iOS Lessons