Почему UIPageViewController?
Написать данную статью меня мотивировал результат работы с UIPageViewController, а скорее процесс постижения работы с ним. Потратив уйму времени в попытках понять, как происходит переключение экранов, посмотрев километры видео и прочитав гору русско и англоязычных источников, в том числе документацию apple, я пришёл к выводу что:
Почти отсутствует описание кейсов средней сложности: например, когда вам нужно менять информацию в таблице в зависимости от перелистывания пользователем страницы, а это далеко не тоже самое, что пролистывать список ссылок на изображения и загружать их по мере движения к следующему?
Почти не объясняется поведение нативных методов, которые и обеспечивают данное пролистывание, например: при пролистывании вперед у вас запускается метод ответственный за пролистывание вперед, а потом за пролистывание назад, далее еще несколько методов ответственных за переход. А иногда это может происходить без запуска методов, ответственных за пролистывание вперед/назад. Запутано?
Мало кто применяет одновременно все методы, необходимые для стабильного пролистывания страниц, большинство либо ограничивается лишь частью, что не дает возможности создать поведение, описанное в пункте 1, либо в бой идет создания множества расширений.
Цель данной статьи объяснить принципы работы нативных методов UIPageViewController на примере воображаемого опросника гастрономического туризма.
В данной статье не предполагается приводить подробный код создания и взаимодействия с таблицей, ячейками, изменения данных в них. Данные части кода будут пропускаться «…».
В рамках данной статьи предполагается, что читатель знаком с патернами делегирования и источника данных.
Если нет возможности изучить подробно статью, рекомендую двигаться к разделу: "Резюме" (в нем также есть рисуночки :))
Гастротуризм
Итак, попробуем разобраться в поведении нативных методов UIPageViewController на пример воображаемого опросника туриста - гурмана.
Суть опросника заключается в том, чтобы собрать с пользователя информацию о наиболее предпочитаемых блюдах, с привязкой к городу и бюджету. Таким образом, у нас будет таблица, информация в которой обновляется по мере пролистывания.
Пользовательский путь выглядит следующим образом:
Любимый город (возможно несколько вариантов ответа)
Ваш бюджет на еду (в рамках каждого выбранного города)
Любимое блюдо (возможно несколько вариантов ответа)
Будем строить наше приложение на основе VIPER (ознакомиться с архитектурой можно здесь: https://habr.com/ru/post/358412/)
Поскольку в рамках данной статьи нас интересует ознакомление с принципами работы UIPageViewController, мы рассмотрим внимательнее только V - ViewController и P - Presenter части архитектуры.
UIPageViewController
Приведу код для UIPageViewController и поясню его ниже:
class GastroJourney: UIPageViewController {
var vc: QuestionViewControllerProtocol
var presenter: GastroJourneyPresenterProtocol?
init() {
vc = QuestionViewController(pageIndex: 0)
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
configure()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad(){
super.viewDidLoad()
}
func configure() {
guard let viewController = vc as? UIViewController else {return}
setViewControllers([viewController], direction: .forward, animated: true, completion: nil)
self.delegate = presenter
self.dataSource = presenter
setCurrentVCdataSourceAndDelegate()
}
func setCurrentVCdataSourceAndDelegate() {
guard let vc = self.viewControllers?.first, let questionVC = vc as? QuestionViewController else {return}
questionVC.questionTableView.delegate = presenter
questionVC.questionTableView.dataSource = presenter
...
}}
Итак GastroJourney - наш контейнер, с вьюконтроллерами, которые мы будем пролистывать. QuestionViewController - это вьюконтроллер, содержащий таблицу с данными, которые мы демонстрируем. Название «Question…» поскольку каждый вьюконтроллер это фактически отдельный вопрос, на который отвечает пользователь (Любимый город? Бюджет?)
Про (pageIndex: 0) поясню позже.
setViewControllers([viewController], direction: .forward, animated: true, completion: nil) -> устанавливаем список вьюконтроллеров, которые будем пролистывать. Здесь важно пояснить, что список - это не каждый пролистываемый экран (в нашем случае 3), а количество вьюконтроллеров, которые отображаются в самом начале при загрузке UIPageViewController. Вы можете спросить: "почему тогда это оформляется виде списка?" - Дело в том, что одномоментно вы можете демонстрировать несколько вьюконтроллеров, например, вы разрабатываете приложение для чтения книг, тогда, вероятно, у вас их будет 2 - левая и правая страница.
Таким образом, setViewControllers([viewController], direction: .forward, animated: true, completion: nil) - это установка именно начальных вьюконтроллеров, а в нашем случае вью контроллера.
UIViewController
Код UIViewController, который используется в рамках UIPageViewController. Сперва приведу код и затем поясню его ниже:
protocol QuestionViewControllerProtocol {
var questionTableView: UITableView {get set}
var tableHeaderTextView: UITextView {get set}
var pageIndex: Int {get set}}
class QuestionViewController: UIViewController, QuestionViewControllerProtocol {
private typealias DQConst = DepQuestionConstants
private struct DepQuestionConstants {
...
}
var questionTableView = UITableView(frame: CGRect.zero, style: .grouped)
var tableHeaderTextView: UITextView = {
let tableHeaderTextView = UITextView(frame: CGRect(x: 0.0, y: 0.0, width: UIScreen.main.bounds.width, height: DQConst.sizeOf60))
tableHeaderTextView.textContainerInset = UIEdgeInsets(top: DQConst.sizeOf30, left: DQConst.sizeOf16, bottom: DQConst.sizeOf30, right: DQConst.sizeOf24)
tableHeaderTextView.isScrollEnabled = false
return tableHeaderTextView
}()
var pageIndex: Int = 0
init(pageIndex: Int) {
super.init(nibName: nil, bundle: nil)
self.pageIndex = pageIndex
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
configure()
setConstraints()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if #available(iOS 15, *){
questionTableView.sectionHeaderTopPadding = 0
}}
func configure() {
configureTableView()
configureTableHeader()
}
func configureTableView() {
view.addSubview(questionTableView)
questionTableView.separatorStyle = .none
questionTableView.backgroundColor = .white
}
func configureTableHeader() {
questionTableView.tableHeaderView = tableHeaderTextView
}
func setConstraints() {
questionTableView.snp.makeConstraints{make in
make.edges.equalToSuperview()
}}}
В коде выше мы создаем вьюконтроллер, содержащий таблицу (questionTableView) с вариантами ответа на вопрос. Поскольку мы хотим, чтобы в шапке таблицы находился вопрос, который будет меняться при переключении экранов, нам необходимо создать кастомную шапку - tableHeaderTextView.
Очень важный элемент, который нужно отметить: свойство pageIndex - именно исходя из данного свойства мы будем говорить GastroJourney (UIPageViewController), какой контролер необходимо вывести на экран.
Presenter
Итак, мы переходим, к самому интересному: написание части кода, ответственной за управление UIPageViewController.
Чтобы работать с UIPageViewController нам потребуется Делегат и ДатаСорс и тем и другим будет выступать презентер, также мы назначим презентер датасорсом и делегатом для таблицы questionTableView класса QuestionViewController:
protocol GastroJourneyPresenterProtocol: AnyObject, UITableViewDelegate, UIPageViewControllerDelegate, UITableViewDataSource, UIPageViewControllerDataSource {
…
}
class GastroJourneyPresenter: GastroJourneyPresenterProtocol {
var listOfQuestions: [QuestionnaireQuestionsModel] {
...
}
- переменная, которая определит количество вопросов, а соответственно экранов
var currentQuestion = 0
- мы начинаем с вопроса с индексом 0. Мы обновляем таблицу данными в зависимости от значения в этой переменной.
var nextIndex = 0
…
В момент переключения экранов UIPageViewController запускает следующие методы:
UITableViewDataSource:
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? - загрузка в память вьюконтроллера следующего за текущим
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? -загрузка в память вьюконтроллера перед текущим
UITableViewDelegate
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) - данный метод запускается непосредственно перед переходом к следующему вьюконтроллеру - именно к тому, что был загружен на шаге 1, если мы листаем вперед, или на шаге 2, если мы листаем обратно
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) - данный метод запускается непосредственно после перехода к вьюконтроллеру, что был загружен на шаге 1, если мы листаем вперед, или на шаге 2, если мы листаем обратно
Методы выше запускаются именно в данном порядке от 1 до 4 в случае пролистывания «вперед», но есть особенность: после 4-го шага может запуститься шаг 1 (т.е как будто начинается следующий круг запуска методов). Происходит это потому что pageViewController производит предварительную подгрузку следующего экрана, на который пользователь пока не перешел. А это значит, что когда мы в следующий раз листнем страницу далее, то весь процесс начнется уже не с 1 шага, а 3-го, поскольку экран уже загружен. Важно помнить, что viewControllerBefore или viewControllerAfter - это именно подругрузка в память тех вьюконтроллеров, на которые может произойти переход, при это далеко необязательно, что переход в итоге произойдет (пользователь может закрыть опросник не пройдя его до конца).
В начала предыдущего абзаца я писал: "...после 4-го шага может запуститься шаг 1..." - "может" - потому что бывают ситуации, в которых шаг 1 после 4-го не запустится, данные ситуации определяются UIPageViewController самостоятельно в заисимости от загрузки системы.
В случае пролистывания назад процесс начинается с шага 2 (метод ...viewControllerBefore...)
Код данных методов представлен ниже:
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
viewControllerAfter viewController -
текущий вьюконтроллер, т.е. откуда начинается пролистывание
guard let currentVC = viewController as? QuestionViewController else {return nil}
let currentIndex = currentVC.pageIndex
условие ниже определит верхнюю границу диапазона, докуда мы можем листать вьюконтроллеры (при пролистывании вперед):
if currentIndex < listOfQuestions.count - 1 {
обновляем нашу таблицу (как писал ранее, код связанный с отображением данных в таблице опускается ввиду нерелевантности для цели статьи)
refreshTable(refreshOnlyHeader: false)
return QuestionViewController(pageIndex: currentQuestion + 1)}
return nil
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
viewControllerBefore viewController -
текущий вьюконтроллер, т.е. откуда начинается пролистывание
guard let currentVC = viewController as? QuestionViewController else {return nil}
let currentIndex = currentVC.pageIndex
условие ниже определит нижнюю границу диапазона, докуда мы можем листать вьюконтроллеры (при пролистывании в обратном направлении):
if currentIndex > 0 {
return QuestionViewController(pageIndex: currentQuestion - 1)
}
return nil
}
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
guard let VC = pendingViewControllers.first, let nextVC = VC as? QuestionViewController else {return}
присваиваем переменной индекс следующего вьюконтроллера, т.е того, к которому осуществляется переход
nextIndex = nextVC.pageIndex
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard let VC = previousViewControllers.first, let previousVC = VC as? QuestionViewController else {return}
let previousIndex = previousVC.pageIndex
как можно догадаться из названия previousViewControllers это вьюконтроллеры (в нашем случае вьюконтроллер) "откуда мы пришли", поскольку мы можем двигаться, как вперед, так и в обратном направлении, индекс предыдущего вьюконтроллера может быть, как больше, так и меньше следующего, поэтому мы пользуемся условиями ниже:
if nextIndex > previousIndex {
currentQuestion = currentQuestion + 1
} else if nextIndex < previousIndex {
currentQuestion = currentQuestion - 1
} else {return}
обновляем нашу таблицу и делегаты с датасорсами для нового вьюконтроллера (как писал ранее, код связанный с отображением данных в таблице опускается ввиду нерелевантности для цели статьи)
view?.setCurrentVCdataSourceAndDelegate()
self.refreshTable(refreshOnlyHeader: false)
}}
Резюме
Пролистывание от экрана 1 к экрану 2:
На рисунке указана нумерация экранов от 1 согласно пользовательскому пути, но для целей работы с UIPageViewController нумерация начинатся с 0.
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) - который вернет новый вьюконтроллер с индексом 1 (т.е мы заходим в условие if, см раздел Presenter).
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) - вернет nil, поскольку индекс вьюконтроллера перед текущим не больше 0.
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) - pendingViewControllers - это те самые (тот самый в нашем случае) вьюконтролер, к которому будет произведен переход, т.е тот вьконтроллер, что возвращает метод шага 1. Сам по себе ...willTransitionTo... ничего не возвращает, но мы вытаскиваем индекс, присвоенный вьюконтроллеру на шаге 1 (напомню из pendingViewControllers), чтобы сохранить его в переменную нашего экземпляра презентера.
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) - на данном заключительном этапе мы прописываем условия обновления нашей таблицы и обновляем ее.
Но это не все, помните мы писали выше, что UIPageViewController может проводить подгрузку вьюконтроллеров? - Это именно то, что происходит на шаге 5
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) - который вернет новый вьюконтроллер с индексом 2 (т.е мы заходим в условие if, см раздел Presenter)
Пролистывание от экрана 2 к экрану 3:
Поскольку подгрузка следующего экрана произошла, весь процесс начнется не с … viewControllerAfter…, а
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController])
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool)
А далее UIPageViewcontroller произведет очередную попытку подгрузки вьюконтроллера переход к которому еще не произошел (как это было в шаге 5 выше), но поскольку мы не попадаем в if (текущий индекс не меньше, чем listOfQuestions.count - 1), то данный метод возвращает nil
Однако(!), это не всегда справедливо, как писал ранее: UIPageViewController самостоятельно определяет, в какой момент производить подгрузку следующего экрана ( и производить ли в принципе), исходя из доступных ресурсов системы, т.е вы вполне можете столкнутся с ситуаций, в которой шаг 5 не произойдет. Таким образом, весь цикл целиком повторится вновь начиная со следующего пролистывания. Это справедливо и для пролистывания назад.
В нашем примере при пролистывании назад процесс начнется также с …willTransitionTo… поскольку мы идем назад и предыдущий вьконтроллер уже был подгружен ранее при переходе вперед. После …willTransitionTo… и …didFinishAnimating… UIPageViewcontroller запустит метод func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController), чтобы подгрузить еще один вьюконтроллер (как можно сделать вывод, в памяти не хранятся все вьюконтроллеры, которые уже были, а только тот к которому сейчас происходит переход и следующий за ним).