Это заключительная часть статьи о Core Data, предыдущие части доступны здесь: часть 1 и часть 2.
В этой статье мы повернемся лицом к пользователю и поработаем над интерфейсной частью, помогать нам в этом будет NSFetchRequest и NSFetchedResultsController. Данная часть получилась довольно большой, но я не вижу смысла дробить ее на несколько публикаций. Аккуратнее, под катом много кода и картинок.
Интерфейс — вещь неоднозначная и, в зависимости от требования к продукту, может существенное меняться. В данной статье я не буду уделять ему слишком много времени, точнее говоря, буду уделять совсем мало (я имею ввиду следование Guidelines и тому подобное). Моя задача в данной части статьи состоит в том, чтобы показать, как
Прежде чем начать, давайте придадим модулю делегата приложения (
Давайте начнем со Storyboard:
Теперь необходимо добавить свой класс для Table View Controller:
Незабываем указать этот, созданный нами, класс нашему Table View Controller (
Я не буду здесь использовать Prototype Cells и создавать «кастомный» класс для ячеек таблицы (чтобы сосредоточиться на других вещах), поэтому давайте установим количество таких ячеек равным нулю (
Теперь нам требуется определить источник данных, чтобы реализовать протокол Table View Data Source. В прошлой части мы познакомились с NSFetchRequest и, на первый взгляд, он вроде как подходит для этой цели. С его помощью можно получить список всех объектов в виде массива, что, собственно, нам и нужно. Но мы хотим не только смотреть на список Заказчиков, мы хотим их добавлять, удалять и редактировать. В этом случае, нам придется отслеживать все эти изменения вручную и каждый раз, опять вручную, обновлять наш список. Звучит не очень, да? Но есть другой вариант — NSFetchedResultsController, он очень похож на NSFetchRequest, но он не только возвращает массив нужных нам объектов в момент запроса, но и продолжает следить за всеми записями: если какая-то запись измениться — он нам сообщит об этом, если какие-нибудь записи подгрузятся в фоне через другой управляемый контекст — он нам тоже сообщит об этом. Нам останется только обработать это событие.
Давайте реализуем NSFetchedResultsController в нашем модуле. Я сначала приведу весь код, а следом прокомментирую.
В разделе определения переменных мы создаем объект fetchedResultsController с типом
Затем, при загрузке нашего View Controller (
Также нам надо переопределить две функции для реализации Table View Data Source:
Давайте проверим! Если сейчас запустить приложение и перейти в нашем меню в
Прежде чем продолжать, давайте кое-что немного оптимизируем — создание объекта NSFetchedResultsController не отличается лаконичностью, а нам его надо будет также создавать и для других наших сущностей. При этом, по сути, меняться будет только имя сущности и, возможно, имя поля сортировки. Чтобы не заниматься «копи-пастой» давайте вынесем создание этого объекта в CoreDataManager.
С учетом этого, определение fetchedResultsController измениться на следующее:
Теперь нам надо сделать так, чтобы при выборе какого-нибудь Заказчика открывалась «карточка» со всеми его данными, которые, при необходимости, можно было редактировать. Давайте для этого добавим еще один View Controller (зададим ему заголовок
В качестве типа переход между контроллерами выберите
Также нам надо будет обращаться по имени к этому Segue, давайте укажем имя —
Нам понадобиться свой класс для этого View Controller — все аналогично тому, что мы делали для Table View Controller, только в качестве родительского класса выбираем —
И указываем этот класс для нашего нового View Controller.
Теперь добавим Navigation Bar с двумя кнопками (Save — для сохранения изменений и Cancel — для отмены). Также нам необходимы два текстовых поля для отображения и редактирования информации (name и info). Сделаем два Action (для Save и Cancel) и два Outlet (для name и info).
Интерфейс нашей «карточки» Заказчика готов, теперь надо написать немного кода. Логика будет следующая: при переходе в «карточку» Заказчика из списка Заказчиков мы будем передавать объект customer (Заказчик) на основании выбранной строки списка. При открытии «карточки» данные из этого объекта будут загружаться в элементы интерфейса (
Также, нам надо учесть то, что у нас есть обязательное для заполнение поле — name. Если пользователь попробует сохранить Заказчика с пустым именем, то он получит критическую ошибку. Чтобы этого не произошло, давайте добавим проверку корректности сохраняемых данных: если данные не корректные, то будем показывать соответствующую предупреждение и блокировать запись такого объекта. Пользователь должен либо ввести корректные данные, либо отказаться от записи такого объекта.
И последнее, что нам надо здесь учесть: наверняка, нам захочется не только редактировать существующих Заказчиков, но и добавлять новых. Делать это мы будем следующим образом: в списке Заказчиков добавим кнопку для создания нового Заказчика, которая будет открывать нашу «карточку» передавая в нее nil. А при сохранении данных «карточки» Заказчика мы будем проверять, если объект customer у нас еще не создан (то есть это ввод нового Заказчика), то будем его сразу создавать.
Таким образом, у нас получиться примерно следующий код.
Теперь давайте вернемся в Table View Controller и добавим кнопку создания нового Заказчика (
Этот Action будет открывать «карточку» для создания нового Заказчика, передавая в нее nil.
Осталось сделать так, чтобы при выборе какого-нибудь существующего Заказчика, открывалась его «карточка». Для этого нам понадобиться две процедуры.
В первой процедуре (при выделении строки списка) мы «считываем» текущего Заказчика, а во второй (при переходе из списка в «карточку») — присваиваем ссылку на выбранного Заказчика переменной
Давайте теперь запустим наше приложение и убедимся, что все работает как надо.
Приложение работает, мы можем вводить новых Заказчиков, редактировать существующих, но информация в списке автоматически не обновляется и у нас нет механизма, чтобы удалять ненужного (или ошибочно введенного) Заказчика. Давайте это исправим.
Так как мы здесь используем NSFetchedResultsController, который «знает» о всех этих изменениях, то нам надо просто его «послушать». Для этого надо реализовать протокол делегата NSFetchedResultsControllerDelegate. Объявим, что мы реализуем этот протокол:
Объявим себя делегатом NSFetchedResultsController:
И добавим следующую реализацию этого протокола:
Несмотря на сравнительно больший объем — она достаточно простая. Здесь мы получаем информацию о том, какой объект и как именно изменился, и, в зависимости от типа изменения, мы выполняем различные действия:
Также у нас есть две «вспомогательные» функции,
Осталось только реализовать удаление Заказчика. Это делается довольно просто, нам понадобиться переопределить всего одну небольшую процедуру.
При поступлении команды удаления мы получаем текущий объект по индексу и передаем его управляемому контексту для удаления. Обратите внимание, что тип объекта для удаления должен быть
На этом работа со справочником «Заказчики» завершена. Давайте запустим приложение и проверим его работу.
Как видете, ничего сверхсложного, Core Data прекрасно сочетается со стандартными элементами интерфейса.
Справочник услуги у нас имеет такую же структуру и логику работы, как и справочник заказчиков. Отличия минимальные, поэтому я не буду здесь все детально расписывать, а просто приведу краткий порядок действий (я уверен, что вы можете легко все сделать самостоятельно по данному конспекту):
Должно получиться что-то вроде этого:
С документом будет все немного сложнее, так как каждый документ, во-первых, представлен у нас двумя разными сущностями, а, во-вторых, имеются взаимосвязи, то есть надо обеспечить каким-то образом выбор значения.
Начнем с простого и уже знакомого — создадим Table View Controller со списком документов и View Controller для отображения самого документа (пока без реквизитов, только заготовка). Я не буду повторяться — все по тому же алгоритму, что и справочники.
Создаем два новых контроллера (Table View Controller для списка документов и View Controller для самого документа):
Добавляем Action, создаем
Делаем заготовку для самого документа:
Несколько замечаний:
На этом журнал документов у нас будет будет закончен, осталось сделать сам документ. Надо отметить, что все три раздела (два наших справочника и документ) получились очень похожими (с точки зрения реализации) и возникает вопрос о целесообразности использования разных классов и контроллеров для них вместо одного универсального. Такой подход тоже возможен, но похожесть наших контроллеров обусловлена очень простой моделью данных, в реальных приложениях сущности, как правило, все-таки заметно различаются и, как следствие, контроллеры и интерфейсные решения тоже выглядят совершенно по разному.
Переходим к самому интересному — документу. Давайте отразим все необходимые нам элементы интерфейса:
Должно получить примерно так (дизайн, конечно, отстой, но это здесь не главное, цель у нас сейчас другая):
Теперь нам надо как-то организовать процесс выбора Заказчика: мы должны открыть список Заказчиков, чтобы пользователь мог выбрать нужного, а затем передать выбранный объект обратно в наш контроллер, чтобы мы могли использовать его в документе. Обычно для этого используется механизм делегирования, то есть создание необходимого протокола и его реализация. Но мы пойдем другим путем — я буду здесь использовать захват контекста с помощью замыкания (подробно рассказывать про сам механизм я не буду, так как есть хорошая статья, посвященная именно этому). Это ненамного сложнее, если вообще сложнее, но быстрее реализуется и выглядит гораздо элегантнее.
Учитывая, что нам в дальнейшем надо будет еще, аналогично Заказчику, выбирать и Услуги, можно было бы создать отдельный универсальный контроллер для выбора значений из списка, но, чтобы сэкономить время, давайте воспользуемся уже готовыми, созданными нами контроллерами (список Заказчиков и список Услуг). Для начала давайте соединим View Controller нашего документа с Table View Controller списка Заказчиков с помощью Segue.
И пропишем вызов этого перехода по кнопке выбора Заказчика.
Также, чтобы реализовать захват контекста, нам надо внести небольшие изменения в наш контроллер, который отвечает за отображение списка контрагентов (
А, во-вторых, изменить процедуру выбора текущей строки списка:
Обратите внимание на логику: мы используем опциональную переменную-замыкание, если она не определена — то список работает как обычно, в режиме добавления и редактирования данных, если определена — значит список был вызван из документа для выбора Заказчика.
Теперь вернемся обратно в контроллер документа, чтобы реализовать замыкание. Но перед этим определим процедуры загрузки и сохранения документа. Логика работы здесь будет немного отличаться от работы со справочниками. Как мы помним, при создании нового документа у нас передается nil и самого объекта-документа при открытии View еще нет. Если при работе со справочниками нам это не мешало и мы создавали сам объект только перед записью, то для документа мы будем создавать его сразу, так как при редактировании строк табличной части мы должны будем указать ссылку на конкретный документ. В принципе, ничего не мешает использовать такой же подход и для справочников для единообразия, но в целях демонстрации разных подходов оставим оба варианта.
Таким образом, процедура «чтения» данных в элементы формы будет выглядеть следующим образом:
Обратите внимание: при создании объекта я сразу присвоил документу текущую дату (конструктор
Теперь давайте, наконец-то, реализуем замыкание для выборка Заказчика, это делается довольно просто:
При переходе на Table View Controller мы определяем обработчик, согласно которому, при выборе Заказчика, мы присваиваем его нашему объекту-документу, а также отображаем имя Заказчика на соответствующем элементе управления документа.
На этом механизм выбор Заказчика закончен, давайте удостоверимся, что все работает, как надо.
Теперь давайте займемся табличной частью. Здесь уже должно быть все знакомо. Очевидно, что надо создать
Но, минуточку, если мы будем использовать
Для этого нам надо добавить соответствующий фильтр в
Обратите внимание на эту строку кода:
Здесь мы задаем в качестве ключа сортировки вложенное поле объекта («через точку»). Разве не замечательная возможность?
Теперь вернемся в
Сразу создадим новый View Controller для отображения данных строки документа и назначим ему новый класс
Теперь давайте добавим Segue с именем orderToRowOfOrder (соединив документ и только что созданный View Controller) и реализуем в нашем документе делегаты необходимых протоколов. Все тоже самое, что и в предыдущих контроллерах, здесь ничего принципиального нового нет (чуть ниже я приведу полный текст модуля).
Также, давайте добавим кнопку для добавления строк в табличную часть документа. Здесь есть один нюанс: если раньше при создании нового объекта передавали nil, и сам объект создавали в другом контроллере, то в случае строки табличной части нам каким-то образом надо «прописать» в ней конкретный документ. Это можно сделать разными способами в зависимости от логики работы программы. Мы сделаем наиболее очевидный — передадим не nil, а объект (
Обратите внимание: так как у нас в модели данных была заданная реверсивная связь между сущностями, то нам не надо беспокоиться о ее создании, она будет добавлена автоматически.
На этом работа непосредственно с самим документом у нас завершена. Осталось закончить с View Controller, который отображает информацию по строке документа. Здесь мы будем использовать точно такую же логику, как и при работе шапкой документа. Выбор Услуги также сделаем через захват контекста замыканием.
Давайте для начала добавим Segue с именем
И, во-вторых, изменим функцию выбора строки списка:
Вернемся обратно в
Собственно, всё! На этом работа с документом завершена, давайте все проверим.
Важное замечание!
Мы здесь никак не обрабатывали нажатия на кнопку Cancel, что привело к следующей ситуации. Если мы создали новый документ, а потом передумали его сохранять и нажали Cancel, то он останется висеть в качестве «черновика» в нашем журнале документов, так как из текущего контекста Core Data его никто не удалил. К нему можно вернуться и продолжить заполнять, либо можно удалить принудительно. Но если вернуться в основное меню, а потом снова открыть журнал документов, то черновиков уже не будет, так как при открытии журнала мы считывает данные из хранилища. Все то же самое касается и строк документа. Для нашей программы такое поведение кажется логичным, ну, по крайней мере — допустимым. Но, возможно, такое поведение — это совсем не то, что хочется вам в вашей программе. В этом случае, вы должны реализовать свою логику реагирования на такие события. Не забывайте, что в любом случае, поведение программы должно быть для пользователя абсолютно понятным и прозрачным.
Этот раздел будет совсем не большим (по сравнению с предыдущим). Мы уже успели немного познакомиться с
Мы рассмотрим работу с
Начнем с сортировки данных, посмотрите на следующее определение:
Здесь мы создаем новый объект сортировки данных (
Также напомню, что в качество поля сортировки можно указывать составные поля «через точку» (мы это делали когда сортировали строки табличной части документа). Давайте добавим второй объект сортировки для того, чтобы внутри даты отсортировать документы по наименованию Заказчика.
Собственно, на этом с сортировкой все. Единственное, что еще напомню — если вы активное пользуетесь сортировкой, не забывайте подумать о целесообразности индексирования используемых полей.
Переходим к механизму Предикатов. Здесь используется довольно простой синтаксис, который немного напоминает SQL-подобные запросы. Предикат создается и используется следующим образом:
В конструктор передается форматная строка, следом передаются аргументы. В зависимости от форматной строки, количество передаваемых параметров может меняться. Давайте подробнее рассмотрим форматную строку — здесь используется что-то вроде своего собственного языка запросов. "%K" — означает имя поля (свойства) объекта, "%@" — значение этого поля. Следом указываются аргументы (фактические значения, которые надо подставить в отбор), строго в том же порядке следования. То есть эта форматная строка означает следующее: Order.made == true.
Можно использовать не только операцию ==, но <, >=, != и так далее. Также можно использовать ключевые слова, такие как CONTAINS, LIKE, MATCHES, BEGINSWITH, ENDSWITH, а также AND и OR. Еще можно использовать регулярные выражения. Это действительно очень мощный инструмент. Я не буду здесь перечислять все возможные варианты, они хорошо представлены в официальной документации Apple. В качестве аргументов для имени поля можно, как и в
Осталось только реализовать протокол UITableViewDataSource (это вы уже умеете, здесь ничего нового) и можно проверять.
Все работает, как и должно, мы получили список документов согласно заданным условиям.
На примере простого приложения мы рассмотрели все основные моменты работы с
Я надеюсь, что смог достаточно понятно объяснить основные приемы работы с
Этот проект на GitHub
В этой статье мы повернемся лицом к пользователю и поработаем над интерфейсной частью, помогать нам в этом будет NSFetchRequest и NSFetchedResultsController. Данная часть получилась довольно большой, но я не вижу смысла дробить ее на несколько публикаций. Аккуратнее, под катом много кода и картинок.
Интерфейс — вещь неоднозначная и, в зависимости от требования к продукту, может существенное меняться. В данной статье я не буду уделять ему слишком много времени, точнее говоря, буду уделять совсем мало (я имею ввиду следование Guidelines и тому подобное). Моя задача в данной части статьи состоит в том, чтобы показать, как
Core Data
может очень органично вписаться в элементы управления iOS. Поэтому я буду использовать для этих целей такой интерфейс, при использовании которого взаимодействие элементов управления и Core Data
будет выглядеть проще и нагляднее. Очевидно, что в реальном приложении интерфейсной части надо будет посвятить гораздо больше времени.Справочники
Прежде чем начать, давайте придадим модулю делегата приложения (
AppDelegate.swift
), в котором мы экспериментировали в прошлой части статьи, первоначальный вид. // AppDelegate.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
return true
}
func applicationWillTerminate(application: UIApplication) {
CoreDataManager.instance.saveContext()
}
}
Давайте начнем со Storyboard:
- добавьте на View несколько кнопок — у нас будет два справочника («Заказчики» и «Услуги»), один документ («Заказ») и один отчет по документам
- добавьте Navigation Controller (меню
Editor\Embed In\Navigation Controller
) - добавьте новый Table View Controller с заголовком (Title)
Customers
- соедините добавленный Table View Controller с соответствующей кнопкой основного меню (
Action Segue\Show
)
Теперь необходимо добавить свой класс для Table View Controller:
- меню File \ New \ File…
- в качестве шаблона выбираем Cocoa Class
- выбираем в качестве родительского класса
UITableViewController
и указываем имя нашего класса —CustomersTableViewController
- выбираем где хранить файл и жмем Create
Незабываем указать этот, созданный нами, класс нашему Table View Controller (
Identity Inspector\Custom Class\Class
).Я не буду здесь использовать Prototype Cells и создавать «кастомный» класс для ячеек таблицы (чтобы сосредоточиться на других вещах), поэтому давайте установим количество таких ячеек равным нулю (
Attributes Inspector\Table View\Prototype Cells
).Теперь нам требуется определить источник данных, чтобы реализовать протокол Table View Data Source. В прошлой части мы познакомились с NSFetchRequest и, на первый взгляд, он вроде как подходит для этой цели. С его помощью можно получить список всех объектов в виде массива, что, собственно, нам и нужно. Но мы хотим не только смотреть на список Заказчиков, мы хотим их добавлять, удалять и редактировать. В этом случае, нам придется отслеживать все эти изменения вручную и каждый раз, опять вручную, обновлять наш список. Звучит не очень, да? Но есть другой вариант — NSFetchedResultsController, он очень похож на NSFetchRequest, но он не только возвращает массив нужных нам объектов в момент запроса, но и продолжает следить за всеми записями: если какая-то запись измениться — он нам сообщит об этом, если какие-нибудь записи подгрузятся в фоне через другой управляемый контекст — он нам тоже сообщит об этом. Нам останется только обработать это событие.
Давайте реализуем NSFetchedResultsController в нашем модуле. Я сначала приведу весь код, а следом прокомментирую.
// CustomersTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class CustomersTableViewController: UITableViewController {
var fetchedResultsController:NSFetchedResultsController = {
let fetchRequest = NSFetchRequest(entityName: "Customer")
let sortDescriptor = NSSortDescriptor(key: "name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataManager.instance.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
return fetchedResultsController
}()
override func viewDidLoad() {
super.viewDidLoad()
do {
try fetchedResultsController.performFetch()
} catch {
print(error)
}
}
// MARK: - Table View Data Source
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let sections = fetchedResultsController.sections {
return sections[section].numberOfObjects
} else {
return 0
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as! Customer
let cell = UITableViewCell()
cell.textLabel?.text = customer.name
return cell
}
}
В разделе определения переменных мы создаем объект fetchedResultsController с типом
NSFetchedResultsController
. Как видите, он создается на базе NSFetchRequest
(я создал NSFetchRequest на основании сущности «Customer» и задал сортировку по имени Заказчика). Затем мы создаем сам NSFetchedResultsController
, передав в его конструктор NSFetchRequest
и нужный нам управляемый контекст, дополнительные параметры конструктора (sectionNameKeyPath, cacheName) мы здесь использовать не будем.Затем, при загрузке нашего View Controller (
func viewDidLoad()
) мы запускаем fetchedResultsController на выполнение: try fetchedResultsController.performFetch()
Также нам надо переопределить две функции для реализации Table View Data Source:
- в первой функции мы возвращаем количество объектов в текущей секции (по факту, секции мы здесь не используем, поэтому все объекты будут находиться в одной единственной секции)
- во второй — программно конструируем ячейку для каждого объекта и возвращаем ее.
Давайте проверим! Если сейчас запустить приложение и перейти в нашем меню в
«Customers»
, то мы увидем всех наших заказчиков, которых добавили в прошлой части статьи. Это было не слишком сложно, да?Прежде чем продолжать, давайте кое-что немного оптимизируем — создание объекта NSFetchedResultsController не отличается лаконичностью, а нам его надо будет также создавать и для других наших сущностей. При этом, по сути, меняться будет только имя сущности и, возможно, имя поля сортировки. Чтобы не заниматься «копи-пастой» давайте вынесем создание этого объекта в CoreDataManager.
import CoreData
import Foundation
class CoreDataManager {
// Singleton
static let instance = CoreDataManager()
// Entity for Name
func entityForName(entityName: String) -> NSEntityDescription {
return NSEntityDescription.entityForName(entityName, inManagedObjectContext: self.managedObjectContext)!
}
// Fetched Results Controller for Entity Name
func fetchedResultsController(entityName: String, keyForSort: String) -> NSFetchedResultsController {
let fetchRequest = NSFetchRequest(entityName: entityName)
let sortDescriptor = NSSortDescriptor(key: keyForSort, ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataManager.instance.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
return fetchedResultsController
}
// MARK: - Core Data stack
// ...
С учетом этого, определение fetchedResultsController измениться на следующее:
var fetchedResultsController = CoreDataManager.instance.fetchedResultsController("Customer", keyForSort: "name")
Теперь нам надо сделать так, чтобы при выборе какого-нибудь Заказчика открывалась «карточка» со всеми его данными, которые, при необходимости, можно было редактировать. Давайте для этого добавим еще один View Controller (зададим ему заголовок
«Customer»
) и соединим его с нашим Table View Controller.В качестве типа переход между контроллерами выберите
Present Modally
.Также нам надо будет обращаться по имени к этому Segue, давайте укажем имя —
customersToCustomer
.Нам понадобиться свой класс для этого View Controller — все аналогично тому, что мы делали для Table View Controller, только в качестве родительского класса выбираем —
UIViewController
, имя класса — CustomerViewController
.И указываем этот класс для нашего нового View Controller.
Теперь добавим Navigation Bar с двумя кнопками (Save — для сохранения изменений и Cancel — для отмены). Также нам необходимы два текстовых поля для отображения и редактирования информации (name и info). Сделаем два Action (для Save и Cancel) и два Outlet (для name и info).
Интерфейс нашей «карточки» Заказчика готов, теперь надо написать немного кода. Логика будет следующая: при переходе в «карточку» Заказчика из списка Заказчиков мы будем передавать объект customer (Заказчик) на основании выбранной строки списка. При открытии «карточки» данные из этого объекта будут загружаться в элементы интерфейса (
name
, info), а при сохранении объекта — наоборот, содержимое элементов интерфейса будет переноситься в поля сохраняемого объекта.Также, нам надо учесть то, что у нас есть обязательное для заполнение поле — name. Если пользователь попробует сохранить Заказчика с пустым именем, то он получит критическую ошибку. Чтобы этого не произошло, давайте добавим проверку корректности сохраняемых данных: если данные не корректные, то будем показывать соответствующую предупреждение и блокировать запись такого объекта. Пользователь должен либо ввести корректные данные, либо отказаться от записи такого объекта.
И последнее, что нам надо здесь учесть: наверняка, нам захочется не только редактировать существующих Заказчиков, но и добавлять новых. Делать это мы будем следующим образом: в списке Заказчиков добавим кнопку для создания нового Заказчика, которая будет открывать нашу «карточку» передавая в нее nil. А при сохранении данных «карточки» Заказчика мы будем проверять, если объект customer у нас еще не создан (то есть это ввод нового Заказчика), то будем его сразу создавать.
Таким образом, у нас получиться примерно следующий код.
// CustomerViewController.swift
// core-data-habrahabr-swift
import UIKit
class CustomerViewController: UIViewController {
var customer: Customer?
@IBAction func cancel(sender: AnyObject) {
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func save(sender: AnyObject) {
if saveCustomer() {
dismissViewControllerAnimated(true, completion: nil)
}
}
@IBOutlet weak var nameTextField: UITextField!
@IBOutlet weak var infoTextField: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
// Reading object
if let customer = customer {
nameTextField.text = customer.name
infoTextField.text = customer.info
}
}
func saveCustomer() -> Bool {
// Validation of required fields
if nameTextField.text!.isEmpty {
let alert = UIAlertController(title: "Validation error", message: "Input the name of the Customer!", preferredStyle: .Alert)
alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
return false
}
// Creating object
if customer == nil {
customer = Customer()
}
// Saving object
if let customer = customer {
customer.name = nameTextField.text
customer.info = infoTextField.text
CoreDataManager.instance.saveContext()
}
return true
}
}
Теперь давайте вернемся в Table View Controller и добавим кнопку создания нового Заказчика (
Navigation Item + Bar Button Item
, аналогично карточке Заказчика). И создадим для этой кнопки Action с именем AddCustomer
.Этот Action будет открывать «карточку» для создания нового Заказчика, передавая в нее nil.
@IBAction func AddCustomer(sender: AnyObject) {
performSegueWithIdentifier("customersToCustomer", sender: nil)
}
Осталось сделать так, чтобы при выборе какого-нибудь существующего Заказчика, открывалась его «карточка». Для этого нам понадобиться две процедуры.
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as? Customer
performSegueWithIdentifier("customersToCustomer", sender: customer)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "customersToCustomer" {
let controller = segue.destinationViewController as! CustomerViewController
controller.customer = sender as? Customer
}
}
В первой процедуре (при выделении строки списка) мы «считываем» текущего Заказчика, а во второй (при переходе из списка в «карточку») — присваиваем ссылку на выбранного Заказчика переменной
customer
нашей «карточки», чтобы при ее открытии мы могли считать все данные объекта.Давайте теперь запустим наше приложение и убедимся, что все работает как надо.
Приложение работает, мы можем вводить новых Заказчиков, редактировать существующих, но информация в списке автоматически не обновляется и у нас нет механизма, чтобы удалять ненужного (или ошибочно введенного) Заказчика. Давайте это исправим.
Так как мы здесь используем NSFetchedResultsController, который «знает» о всех этих изменениях, то нам надо просто его «послушать». Для этого надо реализовать протокол делегата NSFetchedResultsControllerDelegate. Объявим, что мы реализуем этот протокол:
class CustomersTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
Объявим себя делегатом NSFetchedResultsController:
override func viewDidLoad() {
super.viewDidLoad()
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print(error)
}
}
И добавим следующую реализацию этого протокола:
// MARK: - Fetched Results Controller Delegate
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case .Insert:
if let indexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
case .Update:
if let indexPath = indexPath {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as! Customer
let cell = tableView.cellForRowAtIndexPath(indexPath)
cell!.textLabel?.text = customer.name
}
case .Move:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
if let newIndexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Automatic)
}
case .Delete:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
Несмотря на сравнительно больший объем — она достаточно простая. Здесь мы получаем информацию о том, какой объект и как именно изменился, и, в зависимости от типа изменения, мы выполняем различные действия:
- Insert (добавление) — вставляем новую строку по указанному индексу (строка добавится не просто в конец списка, а в свое место в списке в соответствии с заданной сортировкой)
- Update (обновление) — данные объекта изменились, получаем строку из нашего списка по указанному индексу и обновляем информацию о ней
- Move (перемещение) — порядок строк изменился (например, Заказчика переименовали и он теперь располагается в соответствии с сортировкой в другом месте), удаляем строку оттуда, где она была и добавляем уже по новому индексу
- Delete (удаление) — удаляем строку по указанному индексу.
Также у нас есть две «вспомогательные» функции,
controllerWillChangeContent
и controllerDidChangeContent
, которые, соответственно, информируют о начале и окончании изменения данных. С помощью этих функций мы сообщаем нашему Table View
, что сейчас мы кое-что изменим в тех данных, которые он отображает (это необходимо для его корректной работы).Осталось только реализовать удаление Заказчика. Это делается довольно просто, нам понадобиться переопределить всего одну небольшую процедуру.
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let managedObject = fetchedResultsController.objectAtIndexPath(indexPath) as! NSManagedObject
CoreDataManager.instance.managedObjectContext.deleteObject(managedObject)
CoreDataManager.instance.saveContext()
}
}
При поступлении команды удаления мы получаем текущий объект по индексу и передаем его управляемому контексту для удаления. Обратите внимание, что тип объекта для удаления должен быть
NSManagedObject
.На этом работа со справочником «Заказчики» завершена. Давайте запустим приложение и проверим его работу.
Как видете, ничего сверхсложного, Core Data прекрасно сочетается со стандартными элементами интерфейса.
Текст модуля CustomersTableViewController.swift
// CustomersTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class CustomersTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
var fetchedResultsController = CoreDataManager.instance.fetchedResultsController("Customer", keyForSort: "name")
override func viewDidLoad() {
super.viewDidLoad()
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print(error)
}
}
@IBAction func AddCustomer(sender: AnyObject) {
performSegueWithIdentifier("customersToCustomer", sender: nil)
}
// MARK: - Table View Data Source
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let sections = fetchedResultsController.sections {
return sections[section].numberOfObjects
} else {
return 0
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as! Customer
let cell = UITableViewCell()
cell.textLabel?.text = customer.name
return cell
}
// MARK: - Table View Delegate
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let managedObject = fetchedResultsController.objectAtIndexPath(indexPath) as! NSManagedObject
CoreDataManager.instance.managedObjectContext.deleteObject(managedObject)
CoreDataManager.instance.saveContext()
}
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as? Customer
performSegueWithIdentifier("customersToCustomer", sender: customer)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "customersToCustomer" {
let controller = segue.destinationViewController as! CustomerViewController
controller.customer = sender as? Customer
}
}
// MARK: - Fetched Results Controller Delegate
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case .Insert:
if let indexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
case .Update:
if let indexPath = indexPath {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as! Customer
let cell = tableView.cellForRowAtIndexPath(indexPath)
cell!.textLabel?.text = customer.name
}
case .Move:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
if let newIndexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Automatic)
}
case .Delete:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
}
Справочник «Услуги»
Справочник услуги у нас имеет такую же структуру и логику работы, как и справочник заказчиков. Отличия минимальные, поэтому я не буду здесь все детально расписывать, а просто приведу краткий порядок действий (я уверен, что вы можете легко все сделать самостоятельно по данному конспекту):
- создаем новый Table View Controller и связываем его с кнопкой
«Services»
- создаем и назначаем для него новый класс
ServicesTableViewController
(на основанииUITableViewController
) - импортируем (
import
)CoreData
, добавляемfetchedResultsController
(на основании сущностиService
) и при загрузке контроллера запускаем его на выполнение - добавляем две процедуры для реализации Table View Data Source, первая — возвращает количество строк, вторая возвращает строку с информацией об объекте по указанному индексу
- создаем новый View Controller для отображения «карточки» услуги и располагаем на нем элементы интерфейса (все аналогично «карточке» заказчика)
- создаем и назначаем новый класс
ServiceViewController
(на основанииUIViewController
) для этого контроллера - создаем два Action (кнопки
Save
иCancel
) и два Outlet (поляname
иinfo
) - добавляем необходимый код (объявляем переменную
service
, прописываем процедуры загрузки и сохранения объекта, не забываем о проверке данных перед записью) - добавляем связь между
ServicesTableViewController
иServiceViewController
с именемservicesToService
(Segue \ Present Modally
) - возвращаемся в
ServicesTableViewController
и добавляем кнопку Add для добавления новой услуги (Navigation Item \ Bar Button Item
) и создаем для нее Action с именемAddService
- прописываем необходимый для переход в карточку новой «услуги» код и реализуем методы Table View Delegate (переход в «карточку» выбранной услуги)
- реализуем методы протокола
NSFetchedResultsControllerDelegate
и объявляем текущий класс в качестве делегата - все, проверяем!
Текст модуля ServicesTableViewController.swift
// ServicesTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class ServicesTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
var fetchedResultsController = CoreDataManager.instance.fetchedResultsController("Service", keyForSort: "name")
@IBAction func AddService(sender: AnyObject) {
performSegueWithIdentifier("servicesToService", sender: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print(error)
}
}
// MARK: - Table View Data Source
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let sections = fetchedResultsController.sections {
return sections[section].numberOfObjects
} else {
return 0
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let service = fetchedResultsController.objectAtIndexPath(indexPath) as! Service
let cell = UITableViewCell()
cell.textLabel?.text = service.name
return cell
}
// MARK: - Table View Delegate
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let managedObject = fetchedResultsController.objectAtIndexPath(indexPath) as! NSManagedObject
CoreDataManager.instance.managedObjectContext.deleteObject(managedObject)
CoreDataManager.instance.saveContext()
}
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let service = fetchedResultsController.objectAtIndexPath(indexPath) as? Service
performSegueWithIdentifier("servicesToService", sender: service)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "servicesToService" {
let controller = segue.destinationViewController as! ServiceViewController
controller.service = sender as? Service
}
}
// MARK: - Fetched Results Controller Delegate
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case .Insert:
if let indexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
case .Update:
if let indexPath = indexPath {
let service = fetchedResultsController.objectAtIndexPath(indexPath) as! Service
let cell = tableView.cellForRowAtIndexPath(indexPath)
cell!.textLabel?.text = service.name
}
case .Move:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
if let newIndexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Automatic)
}
case .Delete:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
}
Текст модуля ServiceViewController.swift
// ServiceViewController.swift
// core-data-habrahabr-swift
import UIKit
class ServiceViewController: UIViewController {
@IBOutlet weak var nameTextField: UITextField!
@IBOutlet weak var infoTextField: UITextField!
@IBAction func cancel(sender: AnyObject) {
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func save(sender: AnyObject) {
if saveService() {
dismissViewControllerAnimated(true, completion: nil)
}
}
var service: Service?
override func viewDidLoad() {
super.viewDidLoad()
// Reading object
if let service = service {
nameTextField.text = service.name
infoTextField.text = service.info
}
}
func saveService() -> Bool {
// Validation of required fields
if nameTextField.text!.isEmpty {
let alert = UIAlertController(title: "Validation error", message: "Input the name of the Service!", preferredStyle: .Alert)
alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
return false
}
// Creating object
if service == nil {
service = Service()
}
// Saving object
if let service = service {
service.name = nameTextField.text
service.info = infoTextField.text
CoreDataManager.instance.saveContext()
}
return true
}
}
Xcode
Должно получиться что-то вроде этого:
Документ
С документом будет все немного сложнее, так как каждый документ, во-первых, представлен у нас двумя разными сущностями, а, во-вторых, имеются взаимосвязи, то есть надо обеспечить каким-то образом выбор значения.
Начнем с простого и уже знакомого — создадим Table View Controller со списком документов и View Controller для отображения самого документа (пока без реквизитов, только заготовка). Я не буду повторяться — все по тому же алгоритму, что и справочники.
Создаем два новых контроллера (Table View Controller для списка документов и View Controller для самого документа):
Добавляем Action, создаем
fetchedResultsController
и реализуем протоколы:Делаем заготовку для самого документа:
Текст модуля OrdersTableViewController.swift
// OrdersTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class OrdersTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
var fetchedResultsController = CoreDataManager.instance.fetchedResultsController("Order", keyForSort: "date")
@IBAction func AddOrder(sender: AnyObject) {
performSegueWithIdentifier("ordersToOrder", sender: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print(error)
}
}
// MARK: - Table View Data Source
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let sections = fetchedResultsController.sections {
return sections[section].numberOfObjects
} else {
return 0
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = UITableViewCell()
let order = fetchedResultsController.objectAtIndexPath(indexPath) as! Order
configCell(cell, order: order)
return cell
}
func configCell(cell: UITableViewCell, order: Order) {
let formatter = NSDateFormatter()
formatter.dateFormat = "MMM d, yyyy"
let nameOfCustomer = (order.customer == nil) ? "-- Unknown --" : (order.customer!.name!)
cell.textLabel?.text = formatter.stringFromDate(order.date) + "\t" + nameOfCustomer
}
// MARK: - Table View Delegate
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let managedObject = fetchedResultsController.objectAtIndexPath(indexPath) as! NSManagedObject
CoreDataManager.instance.managedObjectContext.deleteObject(managedObject)
CoreDataManager.instance.saveContext()
}
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let order = fetchedResultsController.objectAtIndexPath(indexPath) as? Order
performSegueWithIdentifier("ordersToOrder", sender: order)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "ordersToOrder" {
let controller = segue.destinationViewController as! OrderViewController
controller.order = sender as? Order
}
}
// MARK: - Fetched Results Controller Delegate
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case .Insert:
if let indexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
case .Update:
if let indexPath = indexPath {
let order = fetchedResultsController.objectAtIndexPath(indexPath) as! Order
let cell = tableView.cellForRowAtIndexPath(indexPath)
configCell(cell!, order: order)
}
case .Move:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
if let newIndexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Automatic)
}
case .Delete:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
}
Несколько замечаний:
- при создании
fetchedResultsController
поле для сортировки мы указываем как «date», то есть документы будут отсортированы по своей дате - для конструирование ячейки используется отдельная вспомогательная функция
configCell
- так как связь между нашим документом и Заказчиком установлена как один-к-одному, то мы можем обращаться к нему сразу «через точку», что мы и делаем при конструировании текста ячейки.
На этом журнал документов у нас будет будет закончен, осталось сделать сам документ. Надо отметить, что все три раздела (два наших справочника и документ) получились очень похожими (с точки зрения реализации) и возникает вопрос о целесообразности использования разных классов и контроллеров для них вместо одного универсального. Такой подход тоже возможен, но похожесть наших контроллеров обусловлена очень простой моделью данных, в реальных приложениях сущности, как правило, все-таки заметно различаются и, как следствие, контроллеры и интерфейсные решения тоже выглядят совершенно по разному.
Переходим к самому интересному — документу. Давайте отразим все необходимые нам элементы интерфейса:
- Дата документа — для этого подойдет Date Picker
- Заказчик — будет представлен двумя элементами: кнопка для выбора Заказчика из списка и поле ввода (недоступное для редактирования) для отображения выбранного элемента
- Признак завершения — воспользуемся Switch
- Признак оплаты — аналогично предыдущему
- Табличная часть — конечно же Table View. Будем выводим информацию по строке табличной части одной строкой текста, не используя «кастомных» ячеек, чтобы не слишком отвлекаться от сути статьи.
Должно получить примерно так (дизайн, конечно, отстой, но это здесь не главное, цель у нас сейчас другая):
Теперь нам надо как-то организовать процесс выбора Заказчика: мы должны открыть список Заказчиков, чтобы пользователь мог выбрать нужного, а затем передать выбранный объект обратно в наш контроллер, чтобы мы могли использовать его в документе. Обычно для этого используется механизм делегирования, то есть создание необходимого протокола и его реализация. Но мы пойдем другим путем — я буду здесь использовать захват контекста с помощью замыкания (подробно рассказывать про сам механизм я не буду, так как есть хорошая статья, посвященная именно этому). Это ненамного сложнее, если вообще сложнее, но быстрее реализуется и выглядит гораздо элегантнее.
Учитывая, что нам в дальнейшем надо будет еще, аналогично Заказчику, выбирать и Услуги, можно было бы создать отдельный универсальный контроллер для выбора значений из списка, но, чтобы сэкономить время, давайте воспользуемся уже готовыми, созданными нами контроллерами (список Заказчиков и список Услуг). Для начала давайте соединим View Controller нашего документа с Table View Controller списка Заказчиков с помощью Segue.
И пропишем вызов этого перехода по кнопке выбора Заказчика.
@IBAction func choiceCustomer(sender: AnyObject) {
performSegueWithIdentifier("orderToCustomers", sender: nil)
}
Также, чтобы реализовать захват контекста, нам надо внести небольшие изменения в наш контроллер, который отвечает за отображение списка контрагентов (
CustomersTableViewController.swift
). Во-первых необходимо добавить переменную-замыкание:// CustomersTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class CustomersTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
typealias Select = (Customer?) -> ()
var didSelect: Select?
А, во-вторых, изменить процедуру выбора текущей строки списка:
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let customer = fetchedResultsController.objectAtIndexPath(indexPath) as? Customer
if let dSelect = self.didSelect {
dSelect(customer)
dismissViewControllerAnimated(true, completion: nil)
} else {
performSegueWithIdentifier("customersToCustomer", sender: customer)
}
}
Обратите внимание на логику: мы используем опциональную переменную-замыкание, если она не определена — то список работает как обычно, в режиме добавления и редактирования данных, если определена — значит список был вызван из документа для выбора Заказчика.
Теперь вернемся обратно в контроллер документа, чтобы реализовать замыкание. Но перед этим определим процедуры загрузки и сохранения документа. Логика работы здесь будет немного отличаться от работы со справочниками. Как мы помним, при создании нового документа у нас передается nil и самого объекта-документа при открытии View еще нет. Если при работе со справочниками нам это не мешало и мы создавали сам объект только перед записью, то для документа мы будем создавать его сразу, так как при редактировании строк табличной части мы должны будем указать ссылку на конкретный документ. В принципе, ничего не мешает использовать такой же подход и для справочников для единообразия, но в целях демонстрации разных подходов оставим оба варианта.
Таким образом, процедура «чтения» данных в элементы формы будет выглядеть следующим образом:
override func viewDidLoad() {
super.viewDidLoad()
// Creating object
if order == nil {
order = Order()
order?.date = NSDate()
}
if let order = order {
dataPicker.date = order.date
switchMade.on = order.made
switchPaid.on = order.paid
textFieldCustomer.text = order.customer?.name
}
}
Обратите внимание: при создании объекта я сразу присвоил документу текущую дату (конструктор
NSDate()
возвращает текущую дату/время). И процедура записи данных: func saveOrder() {
if let order = order {
order.date = dataPicker.date
order.made = switchMade.on
order.paid = switchPaid.on
CoreDataManager.instance.saveContext()
}
}
Теперь давайте, наконец-то, реализуем замыкание для выборка Заказчика, это делается довольно просто:
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "orderToCustomers" {
let viewController = segue.destinationViewController as! CustomersTableViewController
viewController.didSelect = { [unowned self] (customer) in
if let customer = customer {
self.order?.customer = customer
self.textFieldCustomer.text = customer.name!
}
}
}
}
При переходе на Table View Controller мы определяем обработчик, согласно которому, при выборе Заказчика, мы присваиваем его нашему объекту-документу, а также отображаем имя Заказчика на соответствующем элементе управления документа.
На этом механизм выбор Заказчика закончен, давайте удостоверимся, что все работает, как надо.
Теперь давайте займемся табличной частью. Здесь уже должно быть все знакомо. Очевидно, что надо создать
fetchedResultsController
и реализовать протоколы NSFetchedResultsControllerDelegate
, UITableViewDataSource
и UITableViewDelegate
. Но, минуточку, если мы будем использовать
fetchedResultsController
, созданный аналогично предыдущим — мы действительно получим все строки табличной части, но это будут строки всех документов, а нам нужны строки только текущего документа, того с которым работает пользователь.Для этого нам надо добавить соответствующий фильтр в
fetchRequest
. Делается это через механизм предикатов (NSPredicate
). Мы будем говорить о нем чуть больше в конце статьи, а пока давайте просто добавим для нашего документа (Order.swift
) функцию класса, которая будет возвращать табличную часть документа в виде NSFetchedResultsController
. class func getRowsOfOrder(order: Order) -> NSFetchedResultsController {
let fetchRequest = NSFetchRequest(entityName: "RowOfOrder")
let sortDescriptor = NSSortDescriptor(key: "service.name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
let predicate = NSPredicate(format: "%K == %@", "order", order)
fetchRequest.predicate = predicate
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataManager.instance.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
return fetchedResultsController
}
Обратите внимание на эту строку кода:
let sortDescriptor = NSSortDescriptor(key: "service.name", ascending: true)
Здесь мы задаем в качестве ключа сортировки вложенное поле объекта («через точку»). Разве не замечательная возможность?
Теперь вернемся в
OrderViewController.swift
, нам надо объявить переменную, которая будет содержать табличную часть и инициализировать ее после инициализации самого документа при загрузке View Controller.// OrderViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class OrderViewController: UIViewController {
var order: Order?
var table: NSFetchedResultsController?
//…
override func viewDidLoad() {
super.viewDidLoad()
// Creating object
if order == nil {
order = Order()
order?.date = NSDate()
}
if let order = order {
dataPicker.date = order.date
switchMade.on = order.made
switchPaid.on = order.paid
textFieldCustomer.text = order.customer?.name
table = Order.getRowsOfOrder(order)
table!.delegate = self
do {
try table!.performFetch()
} catch {
print(error)
}
}
}
Сразу создадим новый View Controller для отображения данных строки документа и назначим ему новый класс
RowOfOrderViewController
. Добавим необходимые элементы навигации и управления, Outlet и Action, реализуем процедуры чтения и записи объекта. Также для поля ввода суммы установим цифровую клавиатуру (Keyboard Type = Number Pad
).Теперь давайте добавим Segue с именем orderToRowOfOrder (соединив документ и только что созданный View Controller) и реализуем в нашем документе делегаты необходимых протоколов. Все тоже самое, что и в предыдущих контроллерах, здесь ничего принципиального нового нет (чуть ниже я приведу полный текст модуля).
Также, давайте добавим кнопку для добавления строк в табличную часть документа. Здесь есть один нюанс: если раньше при создании нового объекта передавали nil, и сам объект создавали в другом контроллере, то в случае строки табличной части нам каким-то образом надо «прописать» в ней конкретный документ. Это можно сделать разными способами в зависимости от логики работы программы. Мы сделаем наиболее очевидный — передадим не nil, а объект (
RowOfOrder
), который тут же создадим и установим в нем ссылку на наш документ. @IBAction func AddRowOfOrder(sender: AnyObject) {
if let order = order {
let newRowOfOrder = RowOfOrder()
newRowOfOrder.order = order
performSegueWithIdentifier("orderToRowOfOrder", sender: newRowOfOrder)
}
}
Обратите внимание: так как у нас в модели данных была заданная реверсивная связь между сущностями, то нам не надо беспокоиться о ее создании, она будет добавлена автоматически.
Текст модуля OrderViewController.swift
// OrderViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class OrderViewController: UIViewController, NSFetchedResultsControllerDelegate, UITableViewDataSource, UITableViewDelegate {
var order: Order?
var table: NSFetchedResultsController?
@IBOutlet weak var dataPicker: UIDatePicker!
@IBOutlet weak var textFieldCustomer: UITextField!
@IBOutlet weak var tableView: UITableView!
@IBAction func save(sender: AnyObject) {
saveOrder()
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func cancel(sender: AnyObject) {
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func choiceCustomer(sender: AnyObject) {
performSegueWithIdentifier("orderToCustomers", sender: nil)
}
@IBAction func AddRowOfOrder(sender: AnyObject) {
if let order = order {
let newRowOfOrder = RowOfOrder()
newRowOfOrder.order = order
performSegueWithIdentifier("orderToRowOfOrder", sender: newRowOfOrder)
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
// Creating object
if order == nil {
order = Order()
order!.date = NSDate()
}
if let order = order {
dataPicker.date = order.date
switchMade.on = order.made
switchPaid.on = order.paid
textFieldCustomer.text = order.customer?.name
table = Order.getRowsOfOrder(order)
table!.delegate = self
do {
try table!.performFetch()
} catch {
print(error)
}
}
}
func saveOrder() {
if let order = order {
order.date = dataPicker.date
order.made = switchMade.on
order.paid = switchPaid.on
CoreDataManager.instance.saveContext()
}
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
switch segue.identifier! {
case "orderToCustomers":
let viewController = segue.destinationViewController as! CustomersTableViewController
viewController.didSelect = { [unowned self] (customer) in
if let customer = customer {
self.order?.customer = customer
self.textFieldCustomer.text = customer.name!
}
}
case "orderToRowOfOrder":
let controller = segue.destinationViewController as! RowOfOrderViewController
controller.rowOfOrder = sender as? RowOfOrder
default:
break
}
}
// MARK: - Table View Data Source
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let sections = table?.sections {
return sections[section].numberOfObjects
} else {
return 0
}
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let rowOfOrder = table?.objectAtIndexPath(indexPath) as! RowOfOrder
let cell = UITableViewCell()
let nameOfService = (rowOfOrder.service == nil) ? "-- Unknown --" : (rowOfOrder.service!.name!)
cell.textLabel?.text = nameOfService + " - " + String(rowOfOrder.sum)
return cell
}
// MARK: - Table View Delegate
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let managedObject = table?.objectAtIndexPath(indexPath) as! NSManagedObject
CoreDataManager.instance.managedObjectContext.deleteObject(managedObject)
CoreDataManager.instance.saveContext()
}
}
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let rowOfOrder = table?.objectAtIndexPath(indexPath) as! RowOfOrder
performSegueWithIdentifier("orderToRowOfOrder", sender: rowOfOrder)
}
// MARK: - Fetched Results Controller Delegate
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case .Insert:
if let indexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
case .Update:
if let indexPath = indexPath {
let rowOfOrder = table?.objectAtIndexPath(indexPath) as! RowOfOrder
let cell = tableView.cellForRowAtIndexPath(indexPath)!
let nameOfService = (rowOfOrder.service == nil) ? "-- Unknown --" : (rowOfOrder.service!.name!)
cell.textLabel?.text = nameOfService + " - " + String(rowOfOrder.sum)
}
case .Move:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
if let newIndexPath = newIndexPath {
tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Automatic)
}
case .Delete:
if let indexPath = indexPath {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
}
На этом работа непосредственно с самим документом у нас завершена. Осталось закончить с View Controller, который отображает информацию по строке документа. Здесь мы будем использовать точно такую же логику, как и при работе шапкой документа. Выбор Услуги также сделаем через захват контекста замыканием.
Давайте для начала добавим Segue с именем
rowOfOrderToServices
, который соединит View Controller строки документа и Table View Controller со списком Услуг. Нам надо немного доработать Table View Controller, чтобы мы могли использовать замыкание. Во-первых, добавим переменную-замыкание:// ServicesTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class ServicesTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
typealias Select = (Service?) -> ()
var didSelect: Select?
// …
И, во-вторых, изменим функцию выбора строки списка:
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let service = fetchedResultsController.objectAtIndexPath(indexPath) as? Service
if let dSelect = self.didSelect {
dSelect(service)
dismissViewControllerAnimated(true, completion: nil)
} else {
performSegueWithIdentifier("servicesToService", sender: service)
}
}
Вернемся обратно в
RowOfOrderViewController
и реализуем замыкание. Здесь все по тому же принципу, что и при выборе Заказчика. @IBAction func choiceService(sender: AnyObject) {
performSegueWithIdentifier("rowOfOrderToServices", sender: nil)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "rowOfOrderToServices" {
let controller = segue.destinationViewController as! ServicesTableViewController
controller.didSelect = {[unowned self] (service) in
if let service = service {
self.rowOfOrder!.service = service
self.textFieldService.text = service.name
}
}
}
}
Текст модуля RowOfOrderViewController.swift
// RowOfOrderViewController.swift
// core-data-habrahabr-swift
import UIKit
class RowOfOrderViewController: UIViewController {
var rowOfOrder: RowOfOrder?
@IBAction func cancel(sender: AnyObject) {
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func save(sender: AnyObject) {
saveRow()
dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func choiceService(sender: AnyObject) {
performSegueWithIdentifier("rowOfOrderToServices", sender: nil)
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "rowOfOrderToServices" {
let controller = segue.destinationViewController as! ServicesTableViewController
controller.didSelect = {[unowned self] (service) in
if let service = service {
self.rowOfOrder!.service = service
self.textFieldService.text = service.name
}
}
}
}
@IBOutlet weak var textFieldService: UITextField!
@IBOutlet weak var textFieldSum: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
if let rowOfOrder = rowOfOrder {
textFieldService.text = rowOfOrder.service?.name
textFieldSum.text = String(rowOfOrder.sum)
} else {
rowOfOrder = RowOfOrder()
}
}
func saveRow() {
if let rowOfOrder = rowOfOrder {
rowOfOrder.sum = Float(textFieldSum.text!)!
CoreDataManager.instance.saveContext()
}
}
}
Собственно, всё! На этом работа с документом завершена, давайте все проверим.
Важное замечание!
Мы здесь никак не обрабатывали нажатия на кнопку Cancel, что привело к следующей ситуации. Если мы создали новый документ, а потом передумали его сохранять и нажали Cancel, то он останется висеть в качестве «черновика» в нашем журнале документов, так как из текущего контекста Core Data его никто не удалил. К нему можно вернуться и продолжить заполнять, либо можно удалить принудительно. Но если вернуться в основное меню, а потом снова открыть журнал документов, то черновиков уже не будет, так как при открытии журнала мы считывает данные из хранилища. Все то же самое касается и строк документа. Для нашей программы такое поведение кажется логичным, ну, по крайней мере — допустимым. Но, возможно, такое поведение — это совсем не то, что хочется вам в вашей программе. В этом случае, вы должны реализовать свою логику реагирования на такие события. Не забывайте, что в любом случае, поведение программы должно быть для пользователя абсолютно понятным и прозрачным.
Отчет по документам
Этот раздел будет совсем не большим (по сравнению с предыдущим). Мы уже успели немного познакомиться с
NSFetchRequest
, сейчас рассмотрим его поближе. Давайте сразу создадим новый Table View Controller, создадим и назначим ему новый класс (ReportTableViewController
на основании UITableViewController
).Мы рассмотрим работу с
NSFetchRequest
на примере простого отчета, который будет выводить отсортированный по дате список выполненных, но не оплаченных документов. Для этого мы будем использовать два мощных инструмента, которыми обладает NSFetchRequest
: NSSortDescriptor
— для сортировки данныхNSPredicate
— для задания различных условий отбора (фильтр).
Начнем с сортировки данных, посмотрите на следующее определение:
var fetchRequest = NSFetchRequest(entityName: "Order")
// Sort Descriptor
let sortDescriptor = NSSortDescriptor(key: "date", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
return fetchRequest
}()
Здесь мы создаем новый объект сортировки данных (
NSSortDescriptor
), передавая его конструктору строку, содержащую имя поля сортировки, и указываем желаемое направление сортировки (ascending
: true
— по возрастанию, false
— по убыванию). Обратите внимание, что объекту NSFetchRequest
мы передаем объект сортировки в виде массива. Что это значит? Да, именно это, — мы можем передавать несколько правил сортировки одновременно в виде массива. Также напомню, что в качество поля сортировки можно указывать составные поля «через точку» (мы это делали когда сортировали строки табличной части документа). Давайте добавим второй объект сортировки для того, чтобы внутри даты отсортировать документы по наименованию Заказчика.
var fetchRequest:NSFetchRequest = {
var fetchRequest = NSFetchRequest(entityName: "Order")
// Sort Descriptor
let sortDescriptor1 = NSSortDescriptor(key: "date", ascending: true)
let sortDescriptor2 = NSSortDescriptor(key: "customer.name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
return fetchRequest
}()
Собственно, на этом с сортировкой все. Единственное, что еще напомню — если вы активное пользуетесь сортировкой, не забывайте подумать о целесообразности индексирования используемых полей.
Переходим к механизму Предикатов. Здесь используется довольно простой синтаксис, который немного напоминает SQL-подобные запросы. Предикат создается и используется следующим образом:
// Predicate
let predicate = NSPredicate(format: "%K == %@", "made", true)
fetchRequest.predicate = predicate
В конструктор передается форматная строка, следом передаются аргументы. В зависимости от форматной строки, количество передаваемых параметров может меняться. Давайте подробнее рассмотрим форматную строку — здесь используется что-то вроде своего собственного языка запросов. "%K" — означает имя поля (свойства) объекта, "%@" — значение этого поля. Следом указываются аргументы (фактические значения, которые надо подставить в отбор), строго в том же порядке следования. То есть эта форматная строка означает следующее: Order.made == true.
Можно использовать не только операцию ==, но <, >=, != и так далее. Также можно использовать ключевые слова, такие как CONTAINS, LIKE, MATCHES, BEGINSWITH, ENDSWITH, а также AND и OR. Еще можно использовать регулярные выражения. Это действительно очень мощный инструмент. Я не буду здесь перечислять все возможные варианты, они хорошо представлены в официальной документации Apple. В качестве аргументов для имени поля можно, как и в
NSSortDescriptor
, использовать составные поля («через точку»). Но нельзя использовать несколько предикатов одновременно, вместо этого следует использовать более сложное условие в единственном предикате. С учетом этого, итоговое определение предиката в нашем отчете будет следующее: var fetchRequest:NSFetchRequest = {
var fetchRequest = NSFetchRequest(entityName: "Order")
// Sort Descriptor
let sortDescriptor1 = NSSortDescriptor(key: "date", ascending: true)
let sortDescriptor2 = NSSortDescriptor(key: "customer.name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
// Predicate
let predicate = NSPredicate(format: "%K == %@ AND %K == %@", "made", true, "paid", false)
fetchRequest.predicate = predicate
return fetchRequest
}()
Осталось только реализовать протокол UITableViewDataSource (это вы уже умеете, здесь ничего нового) и можно проверять.
Текст модуля ReportTableViewController.swift
// ReportTableViewController.swift
// core-data-habrahabr-swift
import UIKit
import CoreData
class ReportTableViewController: UITableViewController {
var fetchRequest:NSFetchRequest = {
var fetchRequest = NSFetchRequest(entityName: "Order")
// Sort Descriptor
let sortDescriptor1 = NSSortDescriptor(key: "date", ascending: true)
let sortDescriptor2 = NSSortDescriptor(key: "customer.name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
// Predicate
let predicate = NSPredicate(format: "%K == %@ AND %K == %@", "made", true, "paid", false)
fetchRequest.predicate = predicate
return fetchRequest
}()
var report: [Order]?
override func viewDidLoad() {
super.viewDidLoad()
do {
report = try CoreDataManager.instance.managedObjectContext.executeFetchRequest(fetchRequest) as? [Order]
} catch {
print(error)
}
}
// MARK: - Table View Data Source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if let report = report {
return report.count
} else {
return 0
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = UITableViewCell()
if let report = report {
let order = report[indexPath.row]
let formatter = NSDateFormatter()
formatter.dateFormat = "MMM d, yyyy"
let nameOfCustomer = (order.customer == nil) ? "-- Unknown --" : (order.customer!.name!)
cell.textLabel?.text = formatter.stringFromDate(order.date) + "\t" + nameOfCustomer
}
return cell
}
}
Все работает, как и должно, мы получили список документов согласно заданным условиям.
Итоговый вид Storyboard
Заключение
На примере простого приложения мы рассмотрели все основные моменты работы с
Core Data
и получили, за достаточно короткий срок, полностью работоспособное приложение. Дизайн, конечно, как минимум просит доработки, но данная публикация преследовала другую цель. Стоит еще раз отметить, что вся непосредственная работа с данными, в том числе организация хранилища данных и все возможные проверки на согласованность, скрыта «под капотом» Core Data
, мы практически не задумывались об этом, а работали с управляемыми объектам, как с обычными объектами ООП.Я надеюсь, что смог достаточно понятно объяснить основные приемы работы с
Core Data
, которые, по моему мнению, необходимы любому iOS-разработчику. Замечательно, если вы перестали бояться и, хотя бы немножко, полюбили Core Data
. Спасибо за внимание.Этот проект на GitHub
Поделиться с друзьями
Gentlee
Никогда не мог понять необъяснимое желание разработчиков плодить лишние строки там где не надо:
var fetchRequest = NSFetchRequest(entityName: «Order»)
// Sort Descriptor
let sortDescriptor1 = NSSortDescriptor(key: «date», ascending: true)
let sortDescriptor2 = NSSortDescriptor(key: «customer.name», ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
// Predicate
let predicate = NSPredicate(format: "%K == %@ AND %K == %@", «made», true, «paid», false)
fetchRequest.predicate = predicate
Переписываем:
var fetchRequest = NSFetchRequest(entityName: «Order»)
fetchRequest.predicate = NSPredicate(format: "%K == %@ AND %K == %@", «made», true, «paid», false)
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: «date», ascending: true),
NSSortDescriptor(key: «customer.name», ascending: true)
]
По поводу Core Data — он слишком «древний» и громоздкий. Хотелось бы что то более легковесное и удобное, но кажется Apple совсем не следит за современными трендами (в т.ч. и по части сред разработки).
angryscorp
Причина излишней многословности в коде очевидна — это, все-таки, обучающий материал, где основная цель в том, чтобы читатель понял, что и откуда берется и как потом используется.
В production ваш вариант, конечно же, более уместен.
Gentlee
Но как комментарий «Sort Descriptor» над переменной sortDescriptor1 класса NSSortDescriptor помогает понять что откуда берется и как потом используется? Если б он хотя бы объяснял что делает этот блок кода я бы согласился.
angryscorp
Комментарий сообщает, что здесь мы начинаем работать с объектом Sort Descriptor, сами объяснения даны в тексте. Это довольно-таки распространенный вариант подачи информации в обучающих материалах.