Pisząc aplikację iOSową w którymś momencie natykamy się na potrzebę stworzenia niestandardowych widoków, reużywalnych komponentów interfejsu użytkownika czy po prostu niestandardowych klas widoków. Z pozoru prosty problem, którego rozwiązanie można łatwo wygooglować lub znaleźć na Stack Overflow.

W którymś momencie dojrzewamy do tego, że rzeczy (w naszej aplikacji) chcemy robić dobrze 💪, zgodnie z jakimiś best practice, patternami itp… i wtedy dopiero zaczynają się problemy 😔. Zatem w tym wpisie postaram się wytłumaczyć i opisać, jak działa klasa UIView, jak ją subklasować oraz w jaki sposób inicjalizować nasze niestandardowe widoku bez względu na to, czy pracujesz ze storyboard’ami, czy jesteś ze szkoły pure code 😎.

UIView

Na początek ➡️ początek…

Klasa UIView jest częścią framework’a UIKit i jest reprezentacją dowolnego widoku (części UI) w naszym kodzie. Dziedziczy po klasie UIResponder, ten zaś bezpośrednio z NSObject. W standardzie posiada dwa konstruktory, które możemy nadpisywać w naszej klasie niestandardowej, dziedziczącej po UIView. Jedna z nich przyjmuje jako parametr definicję ramki (frame) naszego widoku. Dzięki temu możemy już przy inicjalizacji umieścić nasz widoku w ramce. Drugi to tajemniczy konstruktor, który Xcode zawsze każe wam inicjalizować jak chcecie napisać coś customowego – mowa o required init?(coder: NSCoder).

To konstruktor, który służy deserializacji naszej klasy. Aplikacja w różnych punktach zapisuje stan naszych widoków, a później potrzebuje ten stan odczytać, aby dany widok odtworzyć. Ponadto gdy nasz widok jest tworzony z plików graficznych, np. typu .xib, to nie posiadamy możliwości przekazania do konstruktora żadnych zmiennych na wejściu (szczegóły w dalszej części wpisu). Zatem każdy konstruktor niestandardowy nie będzie mógł być wywołany automatycznie, a widok i tak musi być jakoś stworzony.

Trochę to wszystko może brzmieć zagmatwanie, ale zaraz wszystko się uporządkuję, gdy zaczniemy operować na przykładzie 😁.

UIView, obok UIViewControllera, to jedna z najbogatszych pod wględem kastomizacji klas w UIKit. Lista możliwości jest spora (więcej w oficjalnej dokumentacji 👉 Apple:UIView). Co jeszcze warto wiedzieć to fakt, że każdy znany nam obiekt graficzny w iOS (jak UIButton czy UILabel) dziedziczą właśnie z UIView.

Tworzenie niestandardowych widoków – pliki XIB 📉

Do tworzenia niestandardowych widoków będzie nam potrzebne stworzenie dwóch plików w strukturze naszego projektu. Najpierw tworzymy widok (xib):

Tworzenie pliku xib

Następnie plik z naszą niestandardową klasą, która będzie dziedziczyła po UIView:

Dodawanie klasy CustomView

Done ✅. Teraz możemy przejść do projektowania naszego widoku w Interface build’erze. Na początek zmieniamy sposób wyświetlania naszego widoku na freeform. Dzięki temu możemy manipulować rozmiarem naszego widoku.

Ustawianie freeform metrics

Design waszego widoku, jakkolwiek skomplikowany, pozostawiam waszej twórczej fantazji 🦋. Dla mnie labelka i przycisk są ok 👌. Kolejnym krokiem jest subklasowanie naszego widoku z plikiem z kodem, czyli z nasza klasą CustomView. I teraz uwaga ‼️ – to jest jeden z ważniejszych etapów – otóż nie subklasujemy naszego elementu widoku, ale określamy naszą klasę jako FileOwner! Jeżeli zasubklasujecie widok, dojdzie do nieskończonej rekurencji, co zaraz zobaczymy podczas inicjalizacji widoku.

Określanie FileOwner

Teraz otwierając Assistant Editor będziemy już w stanie przeciągać nasze elementy bezpośrednio jako Outlety do kodu. Pamiętajmy jeszcze, że nasz widok na ten moment jest jeszcze niewidoczny, ponieważ widok z labelką i przyciskiem nie został dodane do elementu głównego.

Ale że… co 🤔?

Nasz plik CustomView nie jest w tej chwili reprezentacją widoku, ale jedynie jego swoistym kontrolerem. Nie on za to elementu widoku samego w sobie, co oznacza że nasze kontrolki nie zostały dodane do widoku. Dobrą praktyką jest dodanie naszego widoku jako contentView jako Outlet i dopiero wtedy przypisanie mu elementu z pliku graficznego, dokładnie tak jak w przypadku naszej labelki czy przycisku.

FileOwner dla klasy CustomView

I teraz trzeba nasz contentView zainicjalizować i dodać do naszego widoku głównego, czyli do kontrolera CustomView. Robimy to poprzez nadpisanie konstruktorów naszej klasy UIView oraz dopisanie prywatnej metody inicjalizującej.


class CustomView: UIView {

    let nibName = "CustomView"
    
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var button: UIButton!
    @IBOutlet weak var contentView: UIView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        initViews()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initViews()
    }
    
    private func initViews() {
        contentView = UINib(nibName: nibName, bundle: nil).instantiate(withOwner: self, options: nil).first as? UIView
    }
}

Ważna rzecz w tym miejscu – jeżeli używamy (a powinniśmy 😁) autolayout, to właśnie w tym miejscu umieszczamy constraints’y, których nie dodaliśmy w naszym widoku.


class CustomView: UIView {

    let nibName = "CustomView"
    
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var button: UIButton!
    @IBOutlet weak var contentView: UIView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        initViews()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initViews()
    }
    
    private func initViews() {
        contentView = UINib(nibName: nibName, bundle: nil).instantiate(withOwner: self, options: nil).first as? UIView
        addSubview(contentView)
        contentView.translatesAutoresizingMaskIntoConstraints = false
        contentView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        contentView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        contentView.heightAnchor.constraint(equalToConstant: 130).isActive = true
        contentView.widthAnchor.constraint(equalToConstant: 170).isActive = true
    }
}

Tak wygląda moja klasa CustomView obecnie. Kiedy zbudujemy naszą aplikację, powinniśmy już móc dostrzec nasz widok na ekranie, jeżeli go tam dodaliśmy.

Widok skompilowanej aplikacji

To już w zasadzie wszystko ✅. Teraz możemy dodać outlet naszego widoku do ViewController’a i dostosowywać nasz widok do źródła danych!


class ViewController: UIViewController {

    @IBOutlet weak var myCustomView: CustomView!
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        myCustomView.label.text = "Hello from ViewController! "
        myCustomView.button.setTitle("Click me! ", for: .normal)
    }
}

Tworzenie niestandardowych widoków – kod 🖥

No dobrze, wiemy już w jaki sposób można tworzyć widok niestandardowy oraz dodawać do z poziomu storyboard’ów. Jeżeli piszemy kod UI bez użycia Interface Buildera, to w sumie nic prostrzego jako po prostu stworzyć widok i dodać do nadwidoku, czyli do parenta:


let newCustomView = CustomView(frame: CGRect.zero)
view.addSubview(newCustomView)
newCustomView.initConstraints(parent: view) 

I to też zadziała dobrze, ale jeśli chcemy np. korzystać z Dependency Injection (chociażby dla ułatwienia testowania) to trzeba ten kod trochę zrefaktorować. Na wstępie przenieśmy kod inicjalizujący nasz autolayout do samej klasy CustomView:


override func didMoveToSuperview() {
    initAutolayout()
}
    
private func initAutolayout() {
    translatesAutoresizingMaskIntoConstraints = false
    widthAnchor.constraint(equalToConstant: 170).isActive = true
    heightAnchor.constraint(equalToConstant: 130).isActive = true
}

Przy założeniu że zawsze będę korzystał z autolayout i chce żeby mój widok zawsze miał te same wymiary.

Druga rzecz – chcemy pominąć parametryzacje każdej kontrolki oddzielnie, za to chcemy przekazywać parametry do konstruktora (w tym akcje dla przycisku), który nam te parametry odpowiednio przypisze. Tworzymy zatem niestandardowy konstruktor:


typealias DataSource = (labelText: String, buttonText: String, buttonAction: (() -> Void)?)

init(with data: DataSource) {         
	super.init(frame: CGRect.zero)
	self.data = data
	initViews()
}

następnie w metodzie initViews rozpakowujemy źródło danych (bo zadeklarowałem jako optional) i przypisujemy do odpowiednich kontrolek:


guard let data = data else { return }
label.text = data.labelText
button.setTitle(data.buttonText, for: .normal)

Pozostaje nam jeszcze do przypisania akcja przycisku. Jednym z parametrów naszego źródła danych jest akcja, która może zostać przypisana do przycisku. Zatem tworzymy metodę @IBAction i wywołujemy tam metodę z obiektu data:


@IBAction func buttonClicked(_ sender: UIButton) {
	guard let data = data else { return }
    data.buttonAction?()
}

Na koniec wywołanie:


let newCustomView = CustomView(with: ("Hello, ">User!", "Click me!", {
        print("Hello from Custom View!")
    }))
        
view.addSubview(newCustomView)
newCustomView.initConstraints(parent: view)

Test ⚙️ ⚙️ ⚙️… Działa!

Podsumowanie

Tworzenie widoków to jeden z pierwszych złożonych wątków, na który natrafiłem ucząc się programować na iOS. Sam problem nie jest bardzo złożony, ale dobrze jest posiadać wiedzę odnośnie samej architektury aplikacji w systemie, na który piszemy. W internecie można natknąć się na wiele poradników o podobnej tematyce, jednak z uwagi na ich lakoniczność i brak dobrych praktyk postanowiłem zebrać swoją wiedzę i doświadczenia i nakreślić ten temat raz jeszcze. Mam nadzieję że zrozumiale i rozsądnie 😊.

Dzięki za dotarcie do końca! Jeśli uważasz, że jakiś fragment potrzebuje doprecyzowania lub masz jakieś sugestie, podziel się tym ze mną na 🐦@jkornat. Do następnego 👋.

UIView subclassing, czyli widoki niestandardowe

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *