Изображение создано в miro (www.miro.com)
Изображение создано в miro (www.miro.com)

Почему UIPageViewController?

Написать данную статью меня мотивировал результат работы с UIPageViewController,  а скорее процесс постижения работы с ним. Потратив уйму времени в попытках понять, как происходит переключение экранов, посмотрев километры видео и прочитав гору русско и англоязычных источников, в том числе документацию apple, я пришёл к выводу что:

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

  2. Почти не объясняется поведение нативных методов, которые и обеспечивают данное пролистывание, например: при пролистывании вперед у вас запускается метод ответственный за пролистывание вперед, а потом за пролистывание назад, далее еще несколько методов ответственных за переход. А иногда это может происходить без запуска методов, ответственных за пролистывание вперед/назад. Запутано?

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

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

В данной статье не предполагается приводить подробный код создания и взаимодействия с таблицей, ячейками, изменения данных в них. Данные части кода будут пропускаться «…».

В рамках данной статьи предполагается, что читатель знаком с патернами делегирования и источника данных.

Если нет возможности изучить подробно статью, рекомендую двигаться к разделу: "Резюме" (в нем также есть рисуночки :))

Гастротуризм

Итак, попробуем разобраться в поведении нативных методов UIPageViewController на пример воображаемого опросника туриста - гурмана.

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

Пользовательский путь выглядит следующим образом:

  1. Любимый город (возможно несколько вариантов ответа)

  2. Ваш бюджет на еду (в рамках каждого выбранного города)

  3. Любимое блюдо (возможно несколько вариантов ответа)

Будем строить наше приложение на основе 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:

  1. func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? - загрузка в память вьюконтроллера следующего за текущим

  2. func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? -загрузка в память вьюконтроллера перед текущим

UITableViewDelegate

  1. func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) - данный метод запускается непосредственно перед переходом к следующему вьюконтроллеру - именно к тому, что был загружен на шаге 1, если мы листаем вперед, или на шаге 2, если мы листаем обратно

  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:

Изображение создано в miro (www.miro.com)
Изображение создано в miro (www.miro.com)

На рисунке указана нумерация экранов от 1 согласно пользовательскому пути, но для целей работы с UIPageViewController нумерация начинатся с 0.

  1. func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController)  - который вернет новый вьюконтроллер с индексом 1 (т.е мы заходим в условие if, см раздел Presenter).

  2. func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) - вернет nil, поскольку индекс вьюконтроллера перед текущим не больше 0.

  3. func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) - pendingViewControllers - это те самые (тот самый в нашем случае) вьюконтролер, к которому будет произведен переход, т.е тот вьконтроллер, что возвращает метод шага 1. Сам по себе ...willTransitionTo... ничего не возвращает, но мы вытаскиваем индекс, присвоенный вьюконтроллеру на шаге 1 (напомню из pendingViewControllers), чтобы сохранить его в переменную нашего экземпляра презентера.

  4. func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool)  - на данном заключительном этапе мы прописываем условия обновления нашей таблицы и обновляем ее. 

Но это не все, помните мы писали выше, что UIPageViewController может проводить подгрузку вьюконтроллеров? - Это именно то, что происходит на шаге 5

  1. func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController)  - который вернет новый вьюконтроллер с индексом 2 (т.е мы заходим в условие if, см раздел Presenter)

Пролистывание от экрана 2 к экрану 3:

Изображение создано в miro (www.miro.com)
Изображение создано в miro (www.miro.com)

Поскольку подгрузка следующего экрана произошла, весь процесс начнется не с … viewControllerAfter…, а 

  1. func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) 

  2.   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), чтобы подгрузить еще один вьюконтроллер (как можно сделать вывод, в памяти не хранятся все вьюконтроллеры, которые уже были, а только тот к которому сейчас происходит переход и следующий за ним).

Комментарии (0)