GrabDuck

Адаптивные Split View Controller и Popover в iOS 9 (Swift). Часть 2

:

Это вторая часть обучающей статьи, связанной с изучением адаптивного поведения Split View Controller и Popover в iOS 9 на iPad и на iPhone, которое стало возможным благодаря концепции Size Classes. Обучение состоит в создания на Swift практических приложений, работающих с сервером Flickr.com, который является облачным сервисом для хранения фотографий.

В первой части перечислены пять интересных с точки зрения разработчика случаев применения адаптивного Split View Controller и Popover, которые отличаются сложностью Master. Detail везде один и тот же — единственный Image View Controller, вставленный в Navigation Controller и призванный показывать изображение фотографии:

1. Классический вариант: один элемент в Master, вставленный в Navigation Controller, (часто это Table View Controller)

2. Множество Table View Controller элементов, вставленных в Navigation Controller

3. Tab Bar Controller в качестве Master

4. Случай разных UI и разных пользовательских классов для приборов с разными Size Classes здесь не рассматривается, но идею можно посмотреть в “Адаптивный интерфейс с двумя storyboards для iOS 9”.

5. Адаптивный Popover

В первой части осуществлялось построение базового экспериментального приложения на Swift, которое было распространено на случаи 1-2. В этой статье мы будем дальше усложнять наше экспериментальное приложение и распространим его на случаи 3 и 5. Код для всех вариантов можно найти на Github.

3. Tab Bar Controller в качестве Master


Усовершенствуем наше приложение и предоставим пользователю возможность запоминать “недавно” просмотренные фотографии в хранилище NSUserDefaults (то есть они должны постоянно сохранятся между запусками данного приложения) и просматривать их на отдельной закладке Tab Bar Controller. Для этого на основе предыдущего приложения AdaptiveSplitViewController2Swift создадим новое приложение AdaptiveSplitViewController3Swift и добавим Tab Bar Controller на storyboard, удаляем сопровождающие его View Controllers, а вместо них подсоединяем к нему наших Photographers, вставленных в Navigation Controller и другой экранный фрагмент Resents, предназначенный для просмотра “недавних” фотографий и также вставленный в Navigation Controller:

Пробуем запустить приложение и обнаруживаем, что на iPad все работает нормально, а на всех iPhone в Compact-width режиме изображения фотографий с помощью ImageViewController представляются на экране модально с заголовком, но без навигационной панели и без дополнительных кнопок на ней. Но самое печальное то, что мы не можем никуда с этого экрана двинуться:

Это говорит о том, что SplitViewController не может поместить наш ImageViewController в стэк Navigation Controller для Master, так как SplitViewController управляет Master через Tab Bar Controller, который и предлагает модальное представление Detail. Чтобы исправить эту ситуацию, мы можем сами поместить ImageViewController в стэк Navigation Controller для Master и будем это делать в методе showDetailViewController делегата UISplitViewControllerDelegate для Compact-width режима.
Сначала определяем Compact-width режим и получаем Navigation Controller для Master. Затем извлекаем ImageViewController, помещаем его в стэк Navigation Controller для Master и показываем его на экране:

AppDelegate.swift

Возврат true говорит о том, что SplitViewController не должен управлять показом Detail, мы сами будем его показывать. Теперь изображение фотографии для Compact-width приборов будет показываться правильно: c возвратной кнопкой и скользить как в Navigation Controller, а не появляться модально с невозможностью его покинуть:

Но коль скоро мы добавили ImageViewController в стэк Navigation Controller для Master, то можем получить наш ImageViewController в Master при переходе в ландшафтный режим, что, конечно, недопустимо, так как ImageViewController должен появляться только на месте Detail:

Поэтому при переходе в ландшафтный режим нам нужно убрать ImageViewController из стэка Navigation Controller для Master:

AppDelegate.swift

Пользуясь знанием imageURL только что убранного ImageViewController, можем настроить соответствующую строчку в списке фотографий:

AppDelegate.swift

Восстанавливаем Detail со storyboard и настраиваем ее Модель на полученную фотографию:

AppDelegate.swift

В результате ситуация на iPhones поправится, включая и переход от портретного режима к ландшафтному на iPhone 6+:

Теперь поговорим немного о закладке “Resents”, так называемых “недавних” фотографиях. Этот View Controller обслуживается пользовательским классом ResentsTVC

Класс ResentsTVC наследует от класса FlickrPhotosTVC и его единственной функциональной особенностью является считывание списка фотографий из NSUserDefaults:
ResentsTVC.swift

Нам потребовалось всего две строки кода и ниже будет показано почему это так просто.
Взаимодействие с NSUserDefaults организовано с помощью специального класса ResentsDefault и вычисляемого свойства var resentsPhotos:

ResentsDefault.swift

которое представляет собой массив словарей с ключами типа String и значениями типа String. Причем {get} этого свойства считывает данные из NSUserDefaults, а {set} — записывет их в NSUserDefaults. Заметим, что NSUserDefaults — это по существу очень маленькая база данных для постоянного хранения Property List данных между запусками вашего приложения. Нашей внутренней структурой данных для хранения информации о фотографиях, полученных с сервера Flickr.com, мы выбрали массив [Photo], где Photo — это struct, а struct не является ни NSDates, ни NSStrings, ни NSArrays, ни NSDictionarys. Мы не сможем использовать [Photo] для хранения в NSUserDefaults, так как это не Property List.
Я буду использовать массив словарей [[String : String]] для хранения данных о Flickr фотографиях в NSUserDefaults, который является Property List данными, но мне, конечно, понадобится преобразование Photo в [[String : String]] и обратно:

DataModel.swift

Класс ResentsDefault, обслуживающий взаимодействие с NSUserDefaults, имеет в качестве public API два свойства:
var addedPhoto = Photo? — фотографию, которую мы хотим добавить в NSUserDefaults,
var resentsPhotos: [[String : String]] — массив словарей с информацией о фотографиях, хранимых в NSUserDefaults:

ResentsDefault.swift

Наша работа с хранилищем NSUserDefaults начинается с того, что мы добавляем addedPhoto в уже существующее хранилище и это выполняется одной строкой кода и вспомогательной функцией addPhoto, у которой на входе добавляемое photo и массив resentsPhotos фотографий, хранящихся в NSUserDefaults:
addPhoto (photo, inDefaultsPhotos: resentsPhotos)
Когда мы используем resentsPhotos как входной параметр вспомогательной функции, то срабатывает {get} для этого вычисляемого свойства и данные считываются из хранилища NSUserDefaults в resentsPhotos. Затем мы добавляем в самое начало массива resentsPhotos новую фотографию photo как наиболее “недавнюю”:

ResentsDefault.swift

Результирующий массив будет присвоен опять resentsPhotos:
resentsPhotos = addPhoto (photo, inDefaultsPhotos: resentsPhotos)
, а это значит, что работает {set} для этого вычисляемое свойства и обновленный массив записывается в хранилище NSUserDefaults.
Механизм NSUserDefaults предназначен для сохранения очень небольшого количества данных, поэтому мы ограничимся лишь числом фотографий, задаваемых константой ResentPhotoAmount, которая в нашем конкретном случае равна 20.
Теперь подумаем о том, где лучше всего записывать информацию о фотографии в хранилище NSUserDefaults. Если пользователь выбрал фотографию, то мы должны поместить информацию о ней в NSUserDefaults, и лучше всего это сделать в методе prepareForSegue класса FlickrPhotosTVC, но мы оставим этот класс более обобщенным (он может понадобиться нам для других целей). Поэтому разместим новую функциональность, связанную с записью информации о фотографии в NSUserDefaults, в новом классе PhotosSavedNSUserDefaults, который является subclass класса FlickrPhotosTVC:

PhotosSavedNSUserDefaults.swift

Необходимо установить именно новый класс PhotosSavedNSUserDefaults в качестве пользовательского для экранного фрагмента Flickr Photos на storyboard

Но вернемся к классу ResentsTVC, обслуживающему “недавние” фотографии и экранный фрагмент Resents. Его задача восстанавливать информацию о “недавних” фотографиях из NSUserDefaults и мы использовали для этого единственную строку кода:

ResentsTVC.swift

Если мы сравним эту строку с аналогичной строкой еще одного класса JustPostedFlickrPhotosTVC, также наследующего от FlickrPhotosTVC:

JustPostedFlickrPhotosTVC.swift

то мы увидим одну и ту же конструкцию .flatMap(Photo.init). В первом случае при восстановлении из NSUserDefaults на вход поступает [String : String], а во втором случае при считывании данных с сервера Flickr[String : AnyObject]. Как компилятор знает, какой инициализатор Photo запустить? Ведь их два?

DataModel.swift

В Swift у структуры struct может быть несколько инициализаторов, лишь бы они отличались входными параметрами. В нашем случае их два:

  • init?(json:[String:AnyObject]), для инициализации struct Photo из JSON данных с сервера Flickr,
  • init(userDefaults:[String:String]), для инициализации struct Photo из хранилища NSUserDefaults.

Здесь работает механизм вывода типа из контекста ( type Inference) и перегрузки ( overload) функций в зависимости от типа входного параметра. Поэтому вызывается правильный инициализатор struct Photo.
Вывод: для того, чтобы обеспечить адаптивную работу Split View Controller c Table Bar Controller в качестве Master, нужно использовать методы separateSecondaryViewControllerFromPrimaryViewController и showDetailViewController делегата UISplitViewControllerDelegate.
Код находится на Github — приложение AdaptiveSplitViewController3Swift.

4. Адаптивный Popover


Раньше Popover можно было показывать только на iPad, но начиная с iOS 8 он показывается и на iPhone как в портретном, так и в ландшафтном режимах в так называемом “адаптивном” варианте (об этом позже). Концепция Popover осталась той же самой: нам необходим View Controller, как содержимое, которое показывается внутри Popover, но сам по себе Popover — не UIViewController. Он появляется на экране, используя так называемый механизм Popover Presentation Controller.
Продолжим работать с нашим экспериментальным приложением и добавим еще один экранный фрагмент на storyboard, который будет показывать URL изображения выбранной фотографии в Popover «окошке». Для этого на основе приложения AdaptiveSplitViewController2Swift (с многочисленными Table View Controllers в качестве Master) создадим новое приложение AdaptiveSplitViewController4Swift и добавим левую кнопку «URL» на навигационую панель экранного фрагмента Image View Controller (показ изображения фотографии) и segue типа Presents as Popover. Теперь наш пользовательский интерфейс выглядит следующим образом:

Относительно Popover интересно, что хотя он сам по себе не является MVC, он все же использует segue типа Present as Popover, чтобы вызвать появление презентуемого им View Controller.
Для создания segue типа Present as Popover используется как обычно CTRL-перетягивание к некоторому View Controller, и вы также получаете возможность выполнять метод prepareForSegue.
Как только вы установили segue типа Presents as Popover,


появляется возможность регулировать размер “окошка”, а именно размер топового view:

Устанавливаем идентификатор “Show URL” для segue и добавим метод prepareForSegue для ImageViewController:

ImageViewController.swift

Следует обратить внимание на некоторые дополнительные возможности при подготовки segue для Popover. Одна из них — это то, что внутри вашего prepareForSegue вы можете получить то, что называется UIPopoverPresentationController и конфигурировать презентацию Popover. Например, вы можете сказать, что не хотите, чтобы Popover “всплывала” слева от чего-то, а хотите, чтобы Popover всегда “всплывала” справа от чего-то. Вы можете всем этим управлять. Кроме того, в методе prepareForSegue можно определить себя делегатом:

ImageViewController.swift

Далее подтвердить протокол UIPopoverPresentationControllerDelegate:

ImageViewController.swift

и “выключить” адаптивное поведение, реализовав метод делегата UIPopoverPresentationControllerDelegate, в котором возвращается .None, то есть “отказ” от адаптации Popover на iPhone:

ImageViewController.swift

И это все, что нужно для представления Popover ввиде маленького “окошка” на iPhone. Обычно этот способ и встречается в блогах.
Но в нашем экспериментальном приложении мы реализуем это немного по-другому, придерживаясь идеи, что класс ImageViewController должен оставаться независимым от способа показа URL изображения фотографии, а сам экранный фрагмент, на котором размещен TextView для показа URL изображения фотографии, должен быть самонастраивающимся.
Давайте рассмотрим этот второй способ.
Показ URL изображения фотографии обслуживается пользовательским классом URLViewController.
Моделью класса URLViewController является свойство
var url : NSURL?
При его установке, а также в методе “жизненного цикла” viewDidLoad, c помощью метода updateUI() обновляется пользовательский интерфейс. Всю настройку Popover мы будет проводить непосредственно в этом классе, поэтому класс URLViewController подтверждает протокол UIPopoverPresentationControllerDelegate:

В методе “жизненного цикла” awakeFromNib, который вызывается раньше всех, мы устанавливаем стиль презентации .Popover и назначаем себя делегатом протокола UIPopoverPresentationControllerDelegate.

URLViewController.swift

Используя UIPopoverPresentationControllerDelegate, вы можете воздействовать на то, как Popover будет адаптироваться на iPhone. Popover на iPad “всплывает” так, как мы привыкли — в виде маленького окошка. На iPhone Popover адаптируется и превращается вместо маленького окошка в модальное окно на полный экран. Оно не “всплывает” как что-то маленькое на iPhone. Почему? Потому что экран iPhone значительно меньше, и если “всплывающая” вещь реально большая, то может не быть способа сделать ее подходящей размеру экрана. Но если вы представите ее модально на весь экран, то она будет точно соответствовать размеру экрана. iOS автоматически делает эту адаптацию вместо вас, также как автоматически адаптируется Split View Controller и Navigation Controller. Но используя делегата UIPopoverPresentationControllerDelegate, вы можете воздействовать на эту адаптацию, и именно это мы будем делать.
В iOS 9, когда Popover пытается провести презентацию, то спрашивает нас, как мы хотим, чтобы он “адаптировал” Popover на iPhone? По умолчанию, это модальное представление на полный экран (возврат .FullScreen), но мы можем сказать, что хотим использовать UIModalPresentationStyle.None, что означает отсутствие какой-либо автоматической адаптации. То есть презентация на iPhone будет в точности такая же, как и на iPad. Для маленького Popover это имеет большой смысл. Мы можем “выключить” поведение “адаптации”, реализовав метод делегата UIPopoverPresentationControllerDelegate, в котором вернем UIModalPresentationStyle.None:

URLViewController.swift

Последняя и очень важная вещь, касающаяся Popover — это размер “всплывающего” окна.
Вам действительно нравится, когда появляется абсолютно идеально подогнанное к размеру MVC “всплывающее” окно. Потому что MVC могут быть разного размера. В любом случае вы хотели бы реально управлять его размером. В объектно-ориентированном мире система спрашивает MVC, которое находится в Popover, какой размер этот MVC предпочитает? Какого размера ты хочешь быть? Потому что реально только сам MVC может знать, какой размер может быть предпочтительным. Но это только рекомендация, потому что у Popover тоже есть некоторые ограничения. Например, Popover может появляться на экране только в определенном месте, экран должен быть достаточно большим, стрелочки могут иметь определенные направления. У Popover много ограничений такого рода.
Но он все равно спрашивает MVC, которое размещает внутри, чем он хочет быть?
Есть специальное свойство в вашем UIViewController:
var preferredContentSize: CGSize
Вы можете переопределить это свойство и вернуть предпочтительный размер. Если ваш предпочтительный размер всегда один и тот же, вы можете просто его установить. Если вам нужно рассчитать предпочтительный размер на основании его содержимого, то вы можете сделать это следующим образом:

URLViewController.swift

В результате мы получаем на Compact-width приборах такое же маленькое “окошко”, как и на Regular-width приборах:

Есть одна проблема, связанная с работой Popover в портретном режиме на iPad. Если я нахожусь в портретном режиме на iPad и покажу URL для определенной фотографии, а затем я кликну на Flickr Photographers в левой части навигационной панели и выберу другого фотографа и другую фотографию … Вы видите, что предыдущий URL остается на экране, хотя я полагала, что если я кликну где-нибудь за пределами Popover, то Popover уйдет.

У нас уже сменилась картинка, а URL будет оставаться старым до тех пор, пока мы не кликнем на самой картинке за пределами Popover.
Ответ заключается в том, что, если вы кликаете на ту же самую панель, где находится кнопка “URL”, то вам разрешается взаимодействовать со всем, что там находится, то есть кликать на любые кнопки и Popover не уйдет. В частности, когда мы кликаем на возвратной кнопке Flickr Photographers. Эта ситуация имеет отношение к passthroughViews. Целая навигационная панель является частью passthroughViews, и реально плохая вещь заключается в том, что, если я кликну на другой фотографии в списке, то изображение фотографии обновится, а Popover с URL— нет. По-прежнему на экране будет URL старой фотографии. Это действительно очень плохо.
Эту проблему в коде будем решать следующим образом. Каждый раз, когда кто-то устанавливает новое изображение image, я буду убирать (dismiss) любой Popover, который у меня есть.

ImageViewController.swift

Теперь ситуация исправилась

Вывод. Для того, чтобы на Compact-width приборах представлять Popover в виде “маленького окошка”, нужно отменить адаптивную автоматическую презентацию Popover для Compact-width приборов ввиде модального окна на весь экран. Это можно сделать с помощью метода adaptivePresentationStyleForPresentationController
делегата UIPopoverPresentationControllerDelegate, в котором возвращаем значение UIModalPresentationStyle.None:
Код находится на Github — приложение AdaptiveSplitViewController4Swift.