macOS 1 / 1.1. NSTableView



Дальше вы не пройдёте, пока не получите бумаги.

Ганциэль Дуар

В этом уроке


Trivia

Ранее мы изучали разработку приложений под iOS - в данном же случае мы научимся работать с macOS.

Тут появляется множество отличий в разработке:

  1. У приложения может быть несколько окон
  2. Пользователь может растягивать окно приложения, если ему разрешено это
  3. Многие методики разработки отличаются от iOS, так как фреймворки под macOS были созданы гораздо раньше аналогичных для iOS
  4. Вместо касаний и жестов чаще используется управление с помощью мыши и клавиатуры

В этом упражнении мы создадим простую игру, в которой Вы будете угадывать числа, а затем получать сообщения о том, верный был ответ или нет. По игровой логик цифры в числе не повторяются и число может начинаться с нуля.

Эта игра называется Быки и Коровы , в честь угадывания результатов


Создание каркаса приложения

Начните создавать новый проект и выберите macOS:

Там же выберите Cocoa App.

Теперь введите название приложения, как в обычном случае и проверьте, что стоит галочка у Use Storyboards:

Если перейти в раскадровку, то Вы увидите несколько необычных вещей:

  1. Представление для создания меню, которое отображается на верхней линии экрана
  2. Контроллер окна - он отвечает за управление окнами
  3. Контроллерам окон подчиняется несколько контроллеров вида

Выделите представление контроллера и задайте ему размеры:

Перетащите Text Field на полотно и сделайте с ним следующее:

Учтите, что macOS для своих приложений использует систему координат с центром в нижнем левом углу и нормальными осями, как показано на рисунке

Добавим кнопку на полотно:

Теперь добавим табличное представление на полотно и зададим параметры размеров:

Создайте аутлет для табличного представления tableView.

Таблицы в macOS сильно отличаются от iOS - они могут содержать по несколько колонок. Если Вы откроете табличное представление в Document Outline, то удивитесь, как много оно всего содержит. К сожалению для обратной совместимости со старым кодом, на macOS таблицы так и не были упрощены.

Щелкните по зоне заголовков в представлении и введите два заголовка:

Выберите Table View в иерархии документа (не прокручиваемое представление, не Clip View, а именно табличное представление), а затем задайте в инспекторе атрибутов:

Теперь добавим недостающие ограничения в атоматическом режиме:

  1. Выберите контроллер представления целиком
  2. Затем нажмите по 5-ому значку в панели ограничений
  3. И Выберите Reset to Suggested Constraints

Это добавит автоматические ограничения.

Также отдельно задайте ограничение на ширину в 250 точек таблице (ее оборачивающему представлению) через AutoLayout, это позволит нашему приложению иметь фиксированную ширину и не сжиматься меньше.

И последним штрихом щелкните по окну в контроллере окон и в инспекторе атрибутов задайте заголовок окна либо скройте его (Hide Title).

Теперь запустите приложение и Вы получите следующее:

Заметьте, что приложения при разработке в macOS запускаются не в симуляторе, а напрямую.


Работа с табличными представлениями

Установите источником и делегатом для табличного представления сам контроллер представления.

А теперь запустите приложение. Вы ожидали падения приложения, так как контроллер ещё не соответствует протоколам?

Не тут-то было: Вы просто получите в консоли сообщение:

2018-03-30 13:07:24.911910+0500 CowNBulls[2694:351966] *** Illegal NSTableView data source (<CowNBulls.ViewController: 0x6000000e2400>).
Must implement numberOfRowsInTableView: and tableView:objectValueForTableColumn:row:

Если в iOS Вы получите ошибку, то macOS считает что несерьезной проблемой и просто уведомит Вас. Но не пользователя.

Теперь создадим расширение для контроллера представления, в котором подпишем его на протоколы источника данных и делегата;

extension ViewController: NSTableViewDelegate, NSTableViewDataSource {

}

Заметьте, что классы в macOS имеют префиксы NS, а не UI.

В отличие от iOS эти протоколы не содержат обязательных методов.

Реализуем логику игры. Создайте два переменных свойства контроллера - строку answer и массив строк guesses:

var answer = ""
var guesses = [String]()

Прежде, чем двигаться дальше мы импортируем новый для нас Фреймворк:

import GameplayKit

Он содержит различные объекты для создания игровой логики. Мы же используем его для более качественной генерации случайных чисел, чем мы делали ранее.

Добавьте новый метод в контроллер:

func startNewGame(){

}

И его вызов во viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()

    startNewGame()
}

Теперь начнём добавлять в метод начала новой игры следующее:

textField.stringValue = ""

Очищаем текстовое поле. Заметьте, что вместо использования свойства text мы получаем stringValue - это старый стиль Objective-C.

Есть также свойства для получения целочисленного значения поля integerValue. По логике он должен был бы возвращать опциональное значение. Но нет: он вернет число, если содержимое поля приводимо к числу, а в противном случае 0.

guesses.removeAll(keepingCapacity: true)
answer = ""

Очищаем попытки угадывания с сохранением размерности, что позволит сэкономить драгоценные ресурсы на изменение размера массива. А также очищаем ответ.

var numbers = Array(0...9)
//Создаём массив с числами от 0 до 9
numbers = GKRandomSource.sharedRandom().arrayByShufflingObjects(in: numbers) as! [Int]

Здесь мы используем кое-что новенькое - генератор случайных чисел для того, чтобы случайным образом перемешать объекты в массиве. Заметьте, что принимает он гетерогенный массив Any и возвращает его же. Поэтому мы специально приводим его к [Int].

Данный объект-генератор называется разделяемым, так как он делит свой пул с arc4random-генераторами C. Это значит, что обращение к одному из них влияет на результаты другого.

Помимо такого источника можно было воспользоваться другими классами-генераторами случайных чисел:

Учтите, что так же, как и обычные генераторы, данные генераторы неприменимы в криптографии, так как числа получаются псевдослучайными.

Можно получить из генераторов случайные числа с помощью методов протокола GKRandom:

Используя класс GKRandomDistribution, и его подклассы GKGaussianDistribution и GKShuffledDistribution, можно заключать генераторы в распределения величин, что позволит получать значения в заданном диапазоне. Также с их помощью можно симулировать игровые кости (включая заранее созданные 6-и и 20-ти гранные кости d6 и d20).

for _ in 0..<4 {
    answer.append(String(numbers.removeLast()))
}
tableView.reloadData()

Тут создаётся строка ответа путём изъятия из источника чисел.
А в конце мы перезагружаем данные таблицы для очистки от старых попыток.


Источник и делегат

Сперва реализуем число строк:

func numberOfRows(in tableView: NSTableView) -> Int {
    return guesses.count
}

Создадим вспомогательную функцию, сравнивающую ответ пользователя с заданным в программе:

func result(for guess: String) -> String {
    var bulls = 0
    //Число полных совпадений - буква и позиция
    var cows = 0
    //Полусовпадения - буква есть, но не на той позиции

    for (index, letter) in guess.enumerated() {
        if letter == answer[index] {
            bulls += 1
        } else if answer.contains(letter) {
            cows += 1
        }
    }
    //Поэлементно сравниваем ответ пользователя с верным для изменения значений

    return "\(bulls)b \(cows)c"
}

К сожалению, это всё работать не будет - мы получим ошибку:

'subscript' is unavailable: cannot subscript String with an Int, see the documentation comment for discussion

Это связано с тем, что можно получить доступ к элементу строки через сабскрипт только через индексы, а не целочисленные значения.

Исправим это, создав расширение типа String:

extension String {
    subscript(_ i: Int) -> Character {
        return Array(self)[i]
    }
}

Это не самое эффективное решение, так как содержимое копируется в массив.

Запретим выбор ячейки:

func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
    return false
}

Метод для возврата ячейки несколько отличается:

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {

}

Он возвращает представление для строки в столбце.

Допишем внутрь него следующее:

guard let cell = tableView.makeView(withIdentifier: tableColumn!.identifier, owner: self) as? NSTableCellView else {
    return nil
}

Эти строки аналогичны строкам по получению реиспользуемой ячейки для iOS, однако идентификатор передаётся из колонки. А также указывается владелец файла с интерфейсом, который выполняет загрузку представления. Также здесь выполняется приведение к ячейке, так по-умолчанию будет возвращено базовое представление NSView.

Добавим в зависимости от колонки либо значение, введённое пользователем, либо результат отгадывания (а также вернём ячейку):

if tableColumn?.title == "Guess" {
    cell.textField?.stringValue = guesses[row]
} else {
    cell.textField?.stringValue = result(for: guesses[row])
}

return cell

В завершение создадим внутренности метода buttonPressed с нашей логикой по отгадыванию.

Начните с извлечения значения текстового поля в переменную guess:

let guess = textField.stringValue


Проверьте, 4 ли символа в ней ровно и если нет, то верните управление:

guard guess.count == 4 else {
    return
}


Также проверьте, все ли символы отличны (это можно сделать с помощью множества), если нет - также верните управление:

guard Set(guess).count == 4 else {
    return
}


А теперь изучим кое-что новое - множества символов.

let badCharacters = CharacterSet(charactersIn:: "0123456789").inverted
//Этим мы создали набор символов, который включает любые символы кроме тех, что есть в указанной строке

guard guess.rangeOfCharacter(from: badCharacters) == nil else {
    return
}
//Если хоть какой-то символ из строки содержится в строке, введённой пользователем, то ранний выход

Теперь мы добавим ответ в начало массива ответов, а также вставим соответствующую строчку в таблицу:

guesses.insert(guess, at: 0)
tableView.insertRows(at: IndexSet(integer: 0), withAnimation: .slideDown)

И напоследок создадим алерт в случае, если ответ содержит 4 быка:

if result(for: guess).contains("4b") {
    let alert = NSAlert()
    //Создаём новый алерт

    alert.messageText = "You win!"
    //Добавляет тайтл

    alert.informativeText = "Congratulations! Click OK to play again."
    //Добавляем текст информации

    alert.runModal()
    //Запускаем в модальном виде

    //Стоит начать новую игру
    startNewGame()
}

Алерты в macOS менее удобные, чем в других системах Apple - Вам приходится вручную добавлять текст в свойства вместо конструктора.

Подробнее с методами и свойствами алертов в AppKit (создание кнопок реагирования и так далее) можно ознакомиться NSAlert - AppKit | Apple Developer Documentation

Запустите приложение и поиграйте в него!

Когда Вы отгадаете правильный ответ, то появится алерт. В модальном виде Вы не можете вернуться к работе с приложением, пока не закроете алерт.