czwartek, 5 maja 2016

[DSP2016] iOS prosto z poligonu odc.2 (UITableViewController, nawigacja)

Moje wiekopomne dzieło z DSP w wersji na iOS zaliczyło kolejny progres. Standardowy picker na pliki z Music Library niewątpliwie ma zaletę, że jest wszystkim znany, ale nie jawi mi się jako najwygodniejsze rozwiązanie (przechodzenie przez dwa ekrany), nie mówiąc już że kolorystycznie w ogóle nie współgra z czarną, mroczną tonacją mojej app-ki. Podążymy więc alternatywną drogą i napiszemy własny odpowiednik takiego systemowego pickera. Inspirowałem się w pewnym stopniu stylistyką czarnej systemowej app-ki … Stocks oraz rozwiązaniami z systemowej białej Music czy innych tego rodzaju. Jest też wizualne nawiązanie do mojego Light Organ stworzonego wcześniej na Android. Dziś przedstawię jak osiagnąć ekran, taki jak ten poniżej:

IMG_0024

Nie jest to finalna wersja tego ekranu. W kolejnym odcinku wzbogacimy go o dość istotny element, jakim jest wyszukiwarka. Przejdźmy teraz do omówienia tego, co dziś mamy.

Utworzyłem sobie w głównym storyboard nowy ekran (powiązany z kontrolerem FileListViewController), dodałem do niego pasek nawigacyjny w podobny jak ostatnio sposób ustawiając jego styl na Black. Wykonałem w pewnym stopniu ćwiczenie ze strony https://developer.apple.com/library/ios/referencelibrary/GettingStarted/DevelopiOSAppsSwift/Lesson8.html w dokumentacji iOS, opakowując nowy ekran w osobny navigation controller. Pierwszy ekran, ten z lupką otwierać będzie modalnie ten nowy drugi (odpiąłem poprzednią akcję z lupki na rzecz wizualnego zdefiniowania segue, czyli przejścia pomiędzy ekranami poprzez połączenie przycisku z docelowym ekranem). Od razu wrzucę tip jak odpiąć zdefiniowane już akcje na elemencie w designerze. Klikamy w niego mając naciśnięty CTRL. Można to zobaczyć np. na stronie https://developer.apple.com/library/ios/recipes/xcode_help-IB_connections/chapters/Connections.html.

FileListViewController rozszerza standardowy UITableViewController.  Oto jego kod w pełnej krasie:

import UIKit
import MediaPlayer

class FileListViewController: UITableViewController {
   
    @IBOutlet var doneButton: UIBarButtonItem!
   
    var mediaItems: [MPMediaItem]?
    var didPickMediaItems: MPMediaItemCollection?
   
   
    override func viewDidLoad() {
        super.viewDidLoad()
       
        self.tableView.contentInset = UIEdgeInsetsMake(5, 0, 0, 0);
       
        self.tableView.tableFooterView = UIView()
        doneButton.enabled = false
       
        self.loadMediaItemsForMediaType(.Music)
    }
   
    func loadMediaItemsForMediaType(mediaType: MPMediaType){
       
        let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
       
        dispatch_async(queue) {
            let query = MPMediaQuery()
            let mediaTypeNumber =  Int(mediaType.rawValue)
            let predicate = MPMediaPropertyPredicate(value: mediaTypeNumber,
                                                     forProperty: MPMediaItemPropertyMediaType)
            query.addFilterPredicate(predicate)
            self.mediaItems = query.items
           
            dispatch_async(dispatch_get_main_queue()) {
                self.tableView.reloadData()
            }
        }
    }
   
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
   
    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }
   
    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if self.mediaItems != nil {
            return self.mediaItems!.count;
        } else {
            return 0
        }
    }
   
    override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
        cell.textLabel?.textColor = .whiteColor()
        cell.detailTextLabel?.textColor = .lightGrayColor()
    }
   
    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("reuseIdentifier", forIndexPath: indexPath)
        let row = indexPath.row
        let item = self.mediaItems![row] as MPMediaItem
        cell.textLabel?.text = item.valueForProperty(MPMediaItemPropertyTitle) as! String?
       
        var artist = NSLocalizedString("Unknown Artist", comment: "Unknown Artist")
        if let artistVal = item.valueForProperty(MPMediaItemPropertyArtist) as? String {
            artist = artistVal
        }
       
        let length = item.valueForProperty(MPMediaItemPropertyPlaybackDuration) as! Int
       
       
        cell.detailTextLabel?.text = "\(artist)  \(getDisplayTime(length))"
       
       
        cell.tag = row
        return cell
    }
   
    private func getDisplayTime(seconds: Int) -> String {
       
        let h = seconds / 3600
        let m = seconds / 60 - h * 60
        let s = seconds - h * 3600 - m * 60
       
        var str = "";
       
        if h > 0 {
            str += "\(h):"
        }
        str += String(format: "%02d:%02d", m, s)
       
        return str
    }
   
    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
       
        let cell = tableView.cellForRowAtIndexPath(indexPath)
        cell!.accessoryType = .Checkmark
       
        checkDoneButton()
    }
   
    override func tableView(tableView: UITableView, didDeselectRowAtIndexPath indexPath: NSIndexPath) {
       
        let cell = tableView.cellForRowAtIndexPath(indexPath)
        cell!.accessoryType = .None
       
        checkDoneButton()
    }
   
    private func checkDoneButton() {
       
        let selectedRows = self.tableView.indexPathsForSelectedRows ?? []
        doneButton.enabled = !selectedRows.isEmpty
    }
   
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
       
        if doneButton === sender {
           
            if self.mediaItems == nil {
                return
            }
           
            let selectedRows = self.tableView.indexPathsForSelectedRows ?? []
            let noItemsAreSelected = selectedRows.isEmpty
           
            if !noItemsAreSelected {
               
                var items = [MPMediaItem]()
               
                for i in 0 ..< selectedRows.count {
                   
                    let index = selectedRows[i]
                    let item = self.mediaItems![index.row]
                    items.append(item)
                }
               
                self.didPickMediaItems = MPMediaItemCollection(items: items)
            }
        }
    }
   
   
    @IBAction func cancel(sender: UIBarButtonItem) {
       
        dismissViewControllerAnimated(true, completion: nil)
    }
}

Jak osiągnąłem komórkę z obrazkiem, tekstem większym i mniejszym? W designerze zmieniłem rodzaj komórki w jej prototypie z Custom na trochę old-schoolowy Subtitle. Ustawiłem też jej domyślny identyfikator na “reuseIdentifier”, by zażarło to z powyższym kodem. W grę wchodzi oczywiście także typowy kod określający liczbę sekcji, liczbę elementów oraz wstawiający dane do każdej komórki. Znajdziemy do odpowiednio w:  func numberOfSectionsInTableView(tableView: UITableView) –> Int, func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int  oraz func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) –> UITableViewCell.  Trzeba też ustawić obrazek, ale o tym w następnym pytaniu.

Jak podpiąłem ikonkę do projektu w Xcode i ustawiłem ją na każdej komórce? Zaznaczamy w projekcie Assets.xcassets. Na dole klikamy + z dymkiem “Add a group or image set”. Nadajemy set-owi nazwę (u mnie np. AudioIcon). Przeciągamy następnie odpowiednie wersje ikon na pola 1x 2x 3x. Na pewno dobrze zapewnić obrazek dla 2x, co też zrobiłem. Potem ustawiamy Image w designerze we właściwościach prototypu komórki.

Jak ukryłem puste komórki i związane z nimi separatory? Tyle poziomych linii kojarzy mi się z… dzienniczkiem ucznia z lat 90-tych. Aby temu zapobiec stosujemy myk z pustym widokiem: self.tableView.tableFooterView = UIView()

Jak osiągnąłem czarne UITableView ?  Ustawiłem background UITableView w designerze na czarny (w sekcji View!)

Jak osiągnąłem białe litery i szare w komórkach? No, tu już designer nam nie pomoże. Trzeba napisać to jawnie w kodzie w ramach func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath).  BTW widzicie coś takiego jak  = .whiteColor() ?  Swift wnioskuje typ odpowiedni typ i możemy skrótowo tak zapisać odwołanie do jego członka.

Jak zmieniłem kolor separatora na ciemnoszary? W designerze we właściwościach Table View wybrałem pożądany kolor dla Separator.

Jak włączyłem multi-selekcję?  W designerze dla Table View wybrałem Multiple Selection dla Selection.

Jak wyłączyłem inny kolor zaznaczonego wiersza i zrobiłem, że na zaznaczonych pojawiają się znaki akceptacji?  Kolor zaznaczonego wiersza wyłączyłem w designerze ustawiajac na prototypie komórki styl Selection na None.  Z myślą o “ptaszkach” w kodzie ustawiam natomiast coś takiego jak accessoryType w funkcjach tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) oraz  tableView(tableView: UITableView, didDeselectRowAtIndexPath indexPath: NSIndexPath).

Jak zrobiłem odstęp od lewej dolnej krawędzi paska nawigacyjnego? Trochę za mały był odstęp pierwszej komórki od góry jak na mój gust. Zrobiłem sztuczką ze stackoverflow:   self.tableView.contentInset = UIEdgeInsetsMake(5, 0, 0, 0);

Jak załadowałem pliki z bilioteki Music w innym wątku, by nie obciążać głównego wątku UI, a potem znów coś w nim wykonać? Nie musiałem tego robić dla niedużej liczby plików, ale na pewno warto o tym pomyśleć jakby ktoś miał ich dużo na swoim telefonie. Generalnie kod:

         let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
       
        dispatch_async(queue) {
            //zauważalna czasowo czynność do wykonania  w tle          
            dispatch_async(dispatch_get_main_queue()) {
                //użycie wyniku w wątku UI np. z uwagi na operacje na elementach wizualnych w UIKit
            }
        }

robi coś na kształt async z C#. Pobieramy wątek do wykonania w tle poprzez globalną systemową kolejkę, a potem otrzymany wynik chcemy spożytkować znowu w wątku głównym UI poprzez tzw. główną systemową kolejkę. U mnie jest to odświeżenie tabelki, by na pewno pokazały się wyniki jak skończy się wykonywać zapytanie zwracające listę plików muzycznych. W powyższym kodzie prezentowany jest skrótowy zapis pozwalający klauzulę (wyrażenie lambda) wyrzucić poza listę parametrów przekazywanych do funkcji dla lepszej czytelności. Przy okazji natrafiłem na niezłą, całkiem świeżą książkę Beginning iPhone Development with Swift 2.

Jak zrobiłem przejście z ekranu listy na ekran startowy?  Mam tu na myśli scanariusz rezygnacji (przycisk Cancel) oraz powrót z przekazaniem wyniku (przycisk Done). Wykonałem wspomniane już ćwiczenie ze strony https://developer.apple.com/library/ios/referencelibrary/GettingStarted/DevelopiOSAppsSwift/Lesson8.html . Oprogramowujemy funkcję Cancel i łączymy ją z przyciskiem Cancel.  Z myślą o przycisku Done piszemy metodę  func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?), a także nowy kawałek kodu w ViewController:

@IBAction func unwindToPlayer(sender: UIStoryboardSegue) {
       
        if let sourceViewController = sender.sourceViewController as? FileListViewController,
            mediaItemCollection = sourceViewController.didPickMediaItems {
           
            self.collection = mediaItemCollection
            self.player.setQueueWithItemCollection(self.collection)
           
            var playbackState = self.player.playbackState as MPMusicPlaybackState
            if playbackState == .Playing {
                self.player.pause()
            }
           
            let item = self.collection.items[0] as MPMediaItem
            self.player.nowPlayingItem = item
           
            playbackState = self.player.playbackState as MPMusicPlaybackState
            self.player.play()           
        }
    }

Następnie łączymy przycisk Done z Exit całego widoku, do którego należy, wybierając przy tym akcję unwindToPlayer z kontrolera, do którego chcemy powrócić (tzw. scenariusz unwind segue). Dla porządku w ViewController pozbyłem się oczywiście funkcji search, która otwierała systemowy picker oraz implementacji interfejsu MPMediaPickerControllerDelegate. Teraz wszystko zapewnia mi mój “picker”. To tyle na dzisiaj, cześć.

Brak komentarzy: