czwartek, 12 maja 2016

[DSP2016] iOS prosto z poligonu odc.4 (własna kontrolka, Core Graphics, Auto Layout, Xcode, klasy Size)

Witam w ramach kolejnego odcinka w ramach imprezy DSP.  Ileś odcinków temu, jak zajmowałem się Androidem, znalazłem się na etapie, w którym zrobiłem sobie trzy niemrugające jeszcze światła,  tworząc przy tym własny wizualny komponent i definiując elastyczny layout dla różnych orientacji ekranu i różnych stanów aplikacji. Dziś powtórzę ten “wyczyn”, tyle że na iOS.  Na gorąco podzielę się przemyśleniami z implementacji takich ekranów jak poniżej (i w github):

IMG_0029  IMG_0030

Zacznijmy od kwestii związanych z grafiką 2D.  Natrafiłem na jeden z przewodników Apple o grafice 2D. Nie poświęciłem mu jednak dużo uwagi, bardziej posiłkowałem się linkami:

Jednocześnie rozglądałem się za tworzeniem własnych kontrolek. Najpierw do pewnego stopnia zacząłem realizować kroki z oficjalnego tutoriala Apple ze strony https://developer.apple.com/library/ios/referencelibrary/GettingStarted/DevelopiOSAppsSwift/Lesson5.html#//apple_ref/doc/uid/TP40015214-CH19-SW1.

Potem wpadł mi bardziej kapitalny przykład ze strony http://www.appcoda.com/ibdesignable-ibinspectable-tutorial/, dodatkowo pokazujący jak zrobić wsparcie dla designera Xcode !!!  Okazuje się, że nie jest to skomplikowane.  Jest to też oficjalnie udokumentowane

Do stworzenia własnego komponentu przyda się także szczypta wiedzy z cyklu rysowania kontrolki. Można rzucić okiem na stronę https://developer.apple.com/library/ios/documentation/WindowsViews/Conceptual/ViewPG_iPhoneOS/WindowsandViews/WindowsandViews.html#//apple_ref/doc/uid/TP40009503-CH2-SW1.

W efekcie tego wszystkiego powstał następujący kod: 

@IBDesignable
class CircleView: UIView {
   
    @IBInspectable var circleColor: UIColor = UIColor.redColor()
   
   
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        configure()
    }
   
    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
   
    func configure() {
        contentMode = .Redraw
    }
   
    override func drawRect(rect: CGRect) {
        drawCircle(circleColor)
    }   
   
    func drawCircle(color: UIColor) {
       
        let context = UIGraphicsGetCurrentContext()
       
        let a = min(bounds.size.width, bounds.size.height)
        let leftX = CGRectGetMidX(self.bounds) - a / 2
        let topY = CGRectGetMidY(self.bounds) - a / 2
        let rectangle = CGRectMake(leftX, topY, a, a)
       
        CGContextSetFillColorWithColor(context, circleColor.CGColor)
        CGContextFillEllipseInRect(context, rectangle)
    }
}

Najważniejsza jest tutaj funkcja drawRect wywoływana samoistnie przez system, zmiana właściwości circleColor (konfigurowalnej przez designer Xcode, patrz poniżej) także spowoduje przerysowanie.

Screen Shot 2016-05-11 at 22.27.26

Odpowiednie inicjalizatory umożliwiają tworzenie z poziomu kodu i przez designer. Jedyną rzeczą, którą dodałem do kodu kontrolki na koniec (już po zrobieniu wszystkich layotów) była metoda configure ustawiająca contentMode na Redraw. Co to takiego?  Otóż domyślnie narysowana kontrolka jest traktowana przez system jak bitmapa. Jeśli obszar kontrolki ulegnie zmianie, to reprezentująca ją bitmapa zostanie przeskalowana, kontrolka nie jest rysowana ponownie. U mnie to skutkowało elipsami zamiast kół, jeśli zmieniło się coś w layoucie lub po obrocie ekranu (gdzie też mocno zmieniam layout). Dałem opcję wypełnienia z zachowaniem skali i miałem już zawsze kółka, ale po obrocie ekranu te kółka były małe (możliwe że wskutek animacji).  Włączenie przerysowania zniwelowało tę przypadłość.

Przejdźmy teraz do layoutu i designera Xcode zwanego Interface Builderem. W porównaniu z Visual Studio ileś lat wstecz i układanie layoutu można polecić masochistom, ale czego się nie robi w ramach DSP lub gdy jakaś platforma na świecie ma dużą popularność –Winking smile Efekt masochistyczny jest u nas dodatkowo wzmocniony, ponieważ postawiłem wczuć się w realia produkcyjne i moja app-ka działa od iOS 8. Od  iOS 9 miałbym do dyspozycji kontener layoutu UIStackView!  Działam więc na czystym Auto Layout, definiuję sobie wyłącznie warunki odległości każdego elementu od każdego, centrowania, warunki na równe szerokości czy wysokości. Do ogrania mogę polecić odpowiednia część szkolenia  iOS 9 Fundamentals.

Pomocna może być oficjalna strona Adding Auto Layout Constraints with the Pin and Align Tools czy link  http://www.techotopia.com/index.php/Working_with_iOS_7_Auto_Layout_Constraints_in_Interface_Builder.  

Przekrojową wiedzę w tym temacie dostarcza nam sekcja w dokumentacji Apple ze strony https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/AutolayoutPG/.

Wrażenia?  Czasem dostaniesz nie to, co chcesz od designera, musisz sam pilnować, co powstaje (jak zdecydujesz się na suggested constraints, a i nie tylko) i co jakiś czas czyścić. Jak coś nie jest widoczne (np. height 0 czy hidden) to designer nie uwzględni tego elementu jako najbliższego sąsiada w pin-ach. Aby zrobić relatywny layout do paska narzędziowego z przyciskami odtwórz/pauza plus tytuł piosenki musiałem zdefiniować constraint z wysokością na zero, zrobiłem do niego outlet i z poziomu kodu steruje jego priorytem, dzięki czemu raz obowiązuje (999), a raz nie (250):

toolbarHeightConstraint.priority = (playbackState != .Playing && playbackState != .Paused) ? 999 : 250

Zmiana constrainsta nie powoduje w designerze jego automatycznego odświeżenia, trzeba ciągle pilnować czy nie brakuje nam jakichś warunków, czy nie ma jakichś sprzecznych lub czy coś nie wymaga odświeżenia. Coś dla prawdziwych twardzieli.

W orientacji landscape chciałbym mieć inny layout niż w położeniu pionowym. Od iOS 8 mamy coś takiego jak klasy rozmiaru pozwalajace definiować w sposób wizualny wariantowy layout na wszystkie urządzenia i ekrany.  Do edukacji mogę polecić odpowiednią część szkolenia  iOS 9 Fundamentals

Z dokumentacji może przydać się nam przewodnik https://developer.apple.com/library/ios/recipes/xcode_help-IB_adaptive_sizes/_index.html. Do szybkiego ogrania polecam link https://adoptioncurve.net/archives/2014/08/working-with-size-classes-in-interface-builder/.

Apple nie byłby sobą, gdyby nawet tak oczywisty temat nie potraktował na swój oryginalny sposób. Na iPad-ach klasy Size nie pozwalają na rozróżnienie orientacji ekranu!  Co prawda niektórzy stosują sztuczki z programową podmianą klas (np. http://stackoverflow.com/questions/26633172/sizing-class-for-ipad-portrait-and-landscape-modes), ale sobie to już odpuściłem zadowalając się wsparciem dla landscape na iPhone i iPhone Plus poprzez kombinację width=any i height=compact. Warto wiedzieć, że w Xcode można mieć podglądy różnych urządzeń i orientacji bez konieczności uruchamiania emulatora (patrz niżej).

Screen Shot 2016-05-11 at 22.04.49

Animacja przy obrocie telefonu jednak nadal mi się nie końca podobała (widziane chwilowo elipsy), więc zdecydowałem się ją definitywnie wyłączyć. Posiłkując się stackoverflow zastosowałem w ViewController kod:

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
        CATransaction.begin()
        CATransaction.setDisableActions(true)
       
        coordinator.animateAlongsideTransition({ (ctx) -> Void in
           
            }, completion: { (ctx) -> Void in
                CATransaction.commit()
        })
       
    }

Używana jest tutaj funkcja wprowadzona z iOS 8. API z wcześniejszych wersji iOS związane z obsługą zmiany orientacji ekranu stało się deprecated.

Na koniec postanowiłem sobie, tak jak kiedyś w Android, przetestować ustawienie jasności świateł z poziomu kodu. Stworzyłem odpowiednie outlety:

   @IBOutlet var bassLight: CircleView!
   @IBOutlet var midLight: CircleView!
   @IBOutlet var trebleLight: CircleView!  

oraz napisałem funkcje:

    func setLight(light: CircleView, ratio: CGFloat) {
        light.circleColor = getColorWithAlpha(light.circleColor, ratio: ratio)
    }
   
    func getColorWithAlpha(color: UIColor, ratio: CGFloat) -> UIColor {
        var r: CGFloat = 0
        var g: CGFloat = 0
        var b: CGFloat = 0
        var a: CGFloat = 0
       
        if color.getRed(&r, green: &g, blue: &b, alpha: &a) {
            return UIColor(
                red: r,
                green: g,
                blue: b,
                alpha: ratio
            )
        }
       
        return color
       
    }

Widzimy tutaj zwrócenie wyniku poprzez argumenty przekazywane w postaci wskaźników. W metodzie viewDidLoad dla celów testowych wywołałem z sukcesem sekwencję:

    setLight(bassLight, ratio: 0.5)
    setLight(midLight, ratio: 0.1)
    setLight(trebleLight, ratio: 0.8)

I tym prostym akcentem na dziś kończymy.

Brak komentarzy: