poniedziałek, 9 maja 2016

[DSP2016] iOS prosto z poligonu odc.3 (UISearchController & UISearchBar, cykl życia i odtwarzanie stanu aplikacji)

No to jedziemy dalej z tym koksem w ramach DSP. Dziś opowiem jak zrobić sobie całkiem porządną wyszukiwarkę na liście w iOS. W dodatku osadzimy wszystko w ciemnej eleganckiej tonacji (inspirowałem się wyglądem search w czarnym Stocks). Osoby niewtajemniczone mogą bagatelizować, że “phi.. a co to jest zmiana jakiegoś koloru”, ale w iOS takie pozornie nietrudne operacje mogą wymagać niezłych sztuczek! Na koniec przeniesiemy się w jeszcze bardziej zawansowane rejony i powiemy, co zrobić by użytkownik się nie zorientował, że przed powrotem do naszej app-ki system mu ją zatrzymał. Zapraszam do podróży, na której końcu osiągniemy ekran, jak poniżej (i na github):

IMG_0026  IMG_0027

Najpierw zróbmy sobie najbardziej podstawową wersję, tak by zadziałał nam nieostylowany search. Polecam przystępny tutorial ze strony https://www.raywenderlich.com/113772/uisearchcontroller-tutorial. O samym UISearchController można oczywiście poczytać i w oficjalnej dokumentacji.  Warto porównać też sobie powyższy tutorial z nieco bardziej zaawansowanym oficjalnym samplem od Apple dostępnym pod adresem  https://developer.apple.com/library/ios/samplecode/TableSearch_UISearchController/Introduction/Intro.html (zawiera projekty w Objective-C i w Swift). Oto mój FileListViewController po zastosowaniu wskazówek z tutoriala (z pewnymi korektami, ale o tym będzie niżej):

class FileListViewController: UITableViewController, UISearchResultsUpdating {

        var allMediaItems: [MPMediaItem]?
        var filteredMediaItems: [MPMediaItem]?

var selectedMediaItems: [MPMediaItem]?


var searchController: UISearchController!

override func viewDidLoad() {
        …        
        self.configureSearchController()

        …

  selectedMediaItems = [MPMediaItem]()
}

func configureSearchController() {
        searchController = UISearchController(searchResultsController: nil)
        searchController.searchResultsUpdater = self
        searchController.dimsBackgroundDuringPresentation = false
        searchController.searchBar.sizeToFit()
        searchController.searchBar.barTintColor = .blackColor()
        searchController.searchBar.placeholder = "Search Music"
        definesPresentationContext = true
        navigationItem.titleView = searchController.searchBar
        searchController.hidesNavigationBarDuringPresentation = false;
    }

private func getMediaItems() -> [MPMediaItem]? {
        if  searchController.active  && searchController.searchBar.text != "" {
            return filteredMediaItems
        } else {
            return allMediaItems
        }
    }

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        …

        let mediaItems = getMediaItems()
       
       
        let item = mediaItems![row] as MPMediaItem
        …

        
        if selectedMediaItems!.contains(item) {
            cell.accessoryType = .Checkmark
        } else {
            cell.accessoryType = .None
        }
       …        
                
        return cell
    }

override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
       
        let row = indexPath.row
        let mediaItems = getMediaItems()
        let item = mediaItems![row] as MPMediaItem
       
        if !selectedMediaItems!.contains(item) {
            selectedMediaItems!.append(item)
        } else {
            if let index = selectedMediaItems!.indexOf(item) {
                selectedMediaItems!.removeAtIndex(index)
            }
        }
       
        tableView.reloadData()
    }

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
       
        if doneButton === sender {
            if selectedMediaItems!.count > 0 {
                self.didPickMediaItems = MPMediaItemCollection(items: selectedMediaItems!)
            }
        }
    }

func filterContentForSearchText(searchText: String) {
        if self.allMediaItems == nil {
            return
        }
       
        self.filteredMediaItems = self.allMediaItems!.filter { item in
            return mediaItemContainsString(item, searchText: searchText)
        }
       
        tableView.reloadData()
    }
   
    func updateSearchResultsForSearchController(searchController: UISearchController) {
        filterContentForSearchText(searchController.searchBar.text!)
    }

Należy się jak zawsze kilka słów komentarza. Dotychczasową tablicę mediaItems przemianowałem na allMediaItems, doszła tablica filteredMediaItems przechowująca odfiltrowane wyniki. Wszędzie gdzie odwoływałem się do mediaItems wywołuję teraz funkcję getMediaItems zwracającą w zależności od sytuacji odpowiednią tablicę.

Zainicjowanie, skonfigurowanie i osadzenie komponentu search realizuje metoda configureSearchController. Za pomocą linijki  searchController.searchBar.barTintColor = .blackColor()  ustawiam sobie czarne tło, to naokoło pola tekstowego z zaokrągleniami (które na razie pozostaje nieostylowane, białe z czarnymi literami). Za pomocą kodu:

        navigationItem.titleView = searchController.searchBar
        searchController.hidesNavigationBarDuringPresentation = false;

wrzucam pole wyszukiwarki do paska nawigacyjnego. W tutorialu wrzucane jest do nagłówka UITableView. Co prawda taki search napotkamy np. w systemowym pickerze do plików z Music Library, ale denerwuje mnie nieco przesuwanie pole wyszukiwarki podczas scrollowania UITableView!  Dlatego zrobiłem tak, jak w aplikacji Stocks, nieruchomy search przypadł mi zdecydowanie bardziej do gustu. Jest też pułapka w API, jeśli nie napiszemy drugiej linijki z powyższego kodu to pasek nawigacyjny zniknie!  W mojej app-ce pozbyłem się swojego przycisku Cancel i związanej z nim akcji, a także kodu ustawiającego dostępność przycisku Done. Od tej pory Done będzie zawsze aktywny, jak user nic nie wybierze, nic się nie stanie, tylko wyjdziemy z ekranu. API sprawi nam jednak niemiłą niespodziankę, otóż w samym UISearchBar pojawia się drugi przycisk Cancel po wpisaniu czegoś i opcja, która powinna go ukrywać… nie działa!!!  Aby to obejść trzeba stworzyć własną klasę dziedziczącą po UISearchBar, a także własny kontroler rozszerzający UISearchController (polecam http://stackoverflow.com/questions/33227177/hiding-cancel-button-on-search-bar-in-uisearchcontroller):

class CustomSearchBar: UISearchBar {    
    
    override func layoutSubviews() {
        super.layoutSubviews()
        setShowsCancelButton(false, animated: false)
    }    
}

class CustomSearchController: UISearchController, UISearchBarDelegate {
   
    lazy var _searchBar: CustomSearchBar = {
        [unowned self] in
        let result = CustomSearchBar(frame: CGRectZero)
        result.delegate = self
       
        return result
        }()
   
    override var searchBar: UISearchBar {
        get {
            return _searchBar
        }
    }
}

Oczywiście w FileListViewController podmieniamy UISearchController na CustomSearchController:

func configureSearchController() {
        searchController = CustomSearchController(searchResultsController: nil)

  …

}

Teraz rzecz najważniejsza. Poprzez implementację interfejsu UISearchResultsUpdating i metodę filterContentForSearchText zapewniamy reakcję na zmianę tekstu w polu wyszukiwarki.  Teraz wszystko w podstawowym zakresie już jest OK. No prawie… Wnikliwe okno zauważy, że w aktualnej wersji FileListViewController pojawiła się tablica selectedMediaItems do przechowywania informacji, co zostało zaznaczone. Ustawianie accessoryType odbywa się teraz w  func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) –> UITableViewCell. Zmienił się kod w func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath, gdzie po dodaniu lub usunięciu elementu ze wspomnianej tablicy odświeżam całe UITableView poprzez reloadData. W takim układzie deselekcja wierszy w rozumieniu komponentu UI nie będzie się już odbywać, ponieważ po każdej selekcji go odświeżamy. Czemu tak? Wcześniejszy sposób powodował, że przy zmieniającej się liczbie elementów podczas filtrowania po wywołaniu realodData zaznaczenie wiersza o danym indeksie było zachowywane przy prezentacji kolejnego wyniku wyszukiwania (czyli np. zaznaczyłem piosenkę A, a za jakiś czas miałem zaznaczoną B bo miała taki indeks jak wcześniej A).

Teraz wróćmy do UISearchBar. Chcemy go mieć w czarnej luksusowej wersji. Oto kod, dzięki któremu ostatecznie zrealizowałem ten zamiar:

class CustomSearchBar: UISearchBar {   
    
   …    
    
    func indexOfSearchFieldInSubviews() -> Int! {
        var index: Int!
        let searchBarView = subviews[0]
       
        for i in 0 ..< searchBarView.subviews.count {
            if searchBarView.subviews[i].isKindOfClass(UITextField) {
                index = i
                break
            }
        }
       
        return index
    }
   
   
    override func drawRect(rect: CGRect) {
       
        if let index = indexOfSearchFieldInSubviews() {
           
            let searchField: UITextField = subviews[0].subviews[index] as! UITextField
           
            searchField.textColor = .whiteColor()
            searchField.backgroundColor = UIColor(red: 0.14, green: 0.14, blue: 0.14, alpha: 1.0)
           
            if let glassIconView = searchField.leftView as? UIImageView {
                glassIconView.image = glassIconView.image?.imageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate)
                glassIconView.tintColor = UIColor.whiteColor()
            }
           
            let textFieldInsideSearchBarLabel = searchField.valueForKey("placeholderLabel") as? UILabel
            textFieldInsideSearchBarLabel?.textColor = .whiteColor()
           
            let clearButton = searchField.valueForKey("clearButton") as! UIButton
            clearButton.setImage(clearButton.imageView?.image?.imageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate), forState: .Normal)
            clearButton.setImage(clearButton.imageView?.image?.imageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate), forState: .Highlighted)
            clearButton.tintColor = UIColor.whiteColor()
        }
       
        super.drawRect(rect)
    }
}

Trochę komentarza. Polecam tutaj naprawdę kapitalny tutorial o stylowaniu UISearchBar ze strony http://www.appcoda.com/custom-search-bar-tutorial/. Dzięki niemu mamy czarne tło pola tekstowego oraz białe litery. To nie wszystko, dodatkowo zapragnąłem mieć białą lupkę, biały tekst placeholder i białą ikonkę clear zamiast szarych (jak w Stocks). Pomógł mi w tym wpis ze stackoverflow, który zawiera linki do innych dobrych wpisów.

Wznieśmy się teraz na wyższy poziom. Aplikacja iOS żyje sobie w jakimś sensie podobnie do aplikacji Android czy uniwersalnej Windows. Wiedzę o cyklu życia można sobie przyswoić z oficjalnej notatki The App Life Cycle. Bezpośrednio bardziej może się przydać opis zapamiętywania i odtwarzania stanu aplikacji, ale do mnie na początku najbardziej przemówił tutorial ze strony https://www.raywenderlich.com/117471/state-restoration-tutorial. Jak zasymulować potrzebę zapisania stanu przez app-kę, a potem jej uruchomienie by próbowała przywrócić ten stan?  Wołamy przycisk Home (na symulatorze lub na urządzeniu), wołamy Stop z poziomu Xcode (zamykanie przez usera z menadżera włączonych aplikacji powoduje zamknięcie z utratą stanu, nie nadaje się), a następnie Run. Przy tworzeniu kodu obsługującego stan wyszukiwarki posiłkowałem się też samplem Apple, o którym dziś już wspominałem.

Co trzeba zrobić, by to mieć? Najpierw należy zgłosić na poziomie globalnym wsparcie dla obsługi stanu:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    …

    func application(application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
        return true
    }
   
    func application(application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
        return true
    }

}

Potem możemy skorzystać z designera Xcode. Na każdym widoku w main storyboard ustawiłem w Identity Inspector sobie Storyboard ID i przy Restoration ID ustawiłem check “Use Storyboard ID” (można oczywiscie ustawić inny id dla odtwarzania, jak również robić to programowo).  Następnie w ekranie, w którym chcemy zapamiętywać i odzyskiwać stan oprogramowujemy dwie metody: override func encodeRestorableStateWithCoder(coder: NSCoder)  oraz  override func decodeRestorableStateWithCoder(coder: NSCoder), co też uczyniłem w FileListViewController:

class FileListViewController: UITableViewController, UISearchResultsUpdating {
   
    enum RestorationKeys : String {
        case searchControllerIsActive
        case searchBarText
        case searchBarIsFirstResponder
    }
   
    struct SearchControllerRestorableState {
        var wasActive = false
        var wasFirstResponder = false
    }

    …

   var restoredState = SearchControllerRestorableState()

   override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
       
        if restoredState.wasActive {
            searchController.active = restoredState.wasActive
            restoredState.wasActive = false
           
            if restoredState.wasFirstResponder {
                searchController.searchBar.becomeFirstResponder()
                restoredState.wasFirstResponder = false
            }
        }
    }

   …

   private func getMediaItems() -> [MPMediaItem]? {
        if (searchController.active || restoredState.wasActive) && searchController.searchBar.text != "" {
            return filteredMediaItems
        } else {
            return allMediaItems
        }
    }

    override func encodeRestorableStateWithCoder(coder: NSCoder) {
        super.encodeRestorableStateWithCoder(coder)
       
       
        coder.encodeBool(searchController.active, forKey:RestorationKeys.searchControllerIsActive.rawValue)
       
        coder.encodeBool(searchController.searchBar.isFirstResponder(), forKey:RestorationKeys.searchBarIsFirstResponder.rawValue)
       
        coder.encodeObject(searchController.searchBar.text, forKey:RestorationKeys.searchBarText.rawValue)
    }
   
    override func decodeRestorableStateWithCoder(coder: NSCoder) {
        super.decodeRestorableStateWithCoder(coder)       
       
       
        restoredState.wasActive = coder.decodeBoolForKey(RestorationKeys.searchControllerIsActive.rawValue)
       
        restoredState.wasFirstResponder = coder.decodeBoolForKey(RestorationKeys.searchBarIsFirstResponder.rawValue)
       
        searchController.searchBar.text = coder.decodeObjectForKey(RestorationKeys.searchBarText.rawValue) as? String
    }

Nietrudno zauważyć, że odtwarzanie stanu w przypadku wyszukiwarki wymaga zapamiętania części parametrów, by następnie odtworzyć w pełni wizualny stan w metodzie viewDidAppear (nie wszystko z UI jest gotowe do użycia w momencie wywoływania decodeRestorableStateWithCoder). W naszej app-ce pojawił się jeszcze dość niemiły bug, który rozwiązałem. Otóż przy odtwarzaniu stanu zdarzało się, że UITableView miało liczbę wierszy odpowiadajacą wszystkim plikom, natomiast dane były pobierane z tablicy filteredMediaItems, co skutkowało próbą pobrania elementu o nieistniejącym id i wysypaniu aplikacji.  Naprawiłem to poprzez poprawę warunku w funkcji getMediaItems do postaci jak powyżej. Przy okazji analizy zachowania app-ki i jej debugowania wpadł mi link z poręcznym filmikiem przypominającym podstawy debugowania w Xcode.

Teraz możemy cieszyć się już eleganckim search. Następnym razem również będziemy poruszać się w tematach wizualnych, a konkretnie zajmiemy się wizualizacją świateł w iOS.

Brak komentarzy: