Пошаговый туториал по созданию приложения для ведения заметок JustDoIt на базе Core Data.

31 января 2023

Создание проекта

Автор Алексей Ефимов


Открываем Xcode и создаем новый проект на основе шаблона App. Наше приложение будет называться JustDoIt. В качестве интерфейса выбираем Storyboard

Параметр Use Core Data включать не нужно. Базу данным мы интегрируем позже, когда она нам понадобиться. Жмём кнопку Next, выбираем папку в которой хотим сохранить проект и нажимаем кнопку Create.

В настройках проекта удаляем все устройства кроме iPhone. Ориентацию оставляем только портретную.


Статус бар в приложении у нас будет светлым в независимости от системной темы iOS:

Что бы применить выбранный режим необходимо установить параметр NO для ключа View controller-based status bar appearance в файле Info.plist. Переходим в этот файл и добавляем новый ключ. Для этого надо нажать на "+":

В поле для добавления нового ключа начинаем набирать название с большой буквы View И выбираем тот самый ключ:

По умолчанию он уже имеет значение NO. Так и оставляем:

 

 

Подготовка проекта

Открываем класс ViewController, переименовываем его через Refactor в TaskListViewController и меняем тип супер класса на UITableViewController:


class TaskListViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
    }
}      


Данный класс будет отвечать за отображение списка задач.

 

Работа с интерфейсом

Переходим в сториборд и удаляем ViewController, который был нам дан из коробки. Вместо него мы будем использовать связку из UITableViewController и UINavigationController. В библиотеке объектов эта связка вью контроллеров называется Navigation Controller. Для быстрого перехода к этому объекту, в строке поиска набираем первые буквы названия:

  • Хватаем Navigation Controller курсором мыши и перетаскиваем на сториборд.
  • Выбираем Navigation Controller и присваиваем ему параметр Is Initial View Controller, что бы сделать его стартовым:

Слева от Navigation Controller должна появится стрелка, указывающая на то, что вью контроллер является стартовым.

Теперь выбираем Table View Controller, что бы связать его с классом TaskListViewController. Справа в панели открываем вкладку Show Identity Inspector и в поле Class выбираем TaskListViewController.

В проекте у нас горит один warning. Он связан с тем, что мы не задали идентификатор для ячейки табличного представления. Выбираем ячейку и в поле identifier задаем идентификатор “task”. Стиль ячейки в поле Style меняем на Basic:

Поработаем над дизайном лейбла для ячейки. Выбираем лейбл и меняем название шрифта, его размер и цвет:

Теперь поработаем над внешним видом Navigation Bar. Зададим для него все параметры программно. Для этого переходим в класс TaskListViewController и имплементируем приватный метод setupNavigationBar:


    private func setupNavigationBar() {
	    title = "Task List" // 1
        let fontName = "Apple SD Gothic Neo Bold" // 2
        navigationController?.navigationBar.prefersLargeTitles = true // 3
        
        let navBarAppearance = UINavigationBarAppearance() // 4
        navBarAppearance.configureWithOpaqueBackground() // 5
       
        navBarAppearance.titleTextAttributes = [ // 6
            .font: UIFont(name: fontName, size: 19) ?? UIFont.systemFont(ofSize: 19),
            .foregroundColor: UIColor.white
        ]
        navBarAppearance.largeTitleTextAttributes = [ // 7
            .font: UIFont(name: fontName, size: 35) ?? UIFont.systemFont(ofSize: 35),
            .foregroundColor: UIColor.white
        ]
        navBarAppearance.backgroundColor = UIColor( // 8
            red: 97/255,
            green: 210/255,
            blue: 255/255,
            alpha: 255/255
        )

        navigationController?.navigationBar.standardAppearance = navBarAppearance // 9
        navigationController?.navigationBar.scrollEdgeAppearance = navBarAppearance // 10
    }

 

  1. Меняем заголовок экрана на Task List
  2. Определяем название шрифта для заголовка
  3. Делаем заголовок большим
  4. Создаем экземпляр класса UINavigationBarAppearance, который может использоваться для настройки внешнего вида навигационной панели.
  5. Устанавливаем для навигационной панели непрозрачный фон
  6. Задаем ранее выбранный шрифт и белый цвет для обычного заголовка
  7. Задаем те же самые атрибуты для большого заголовка
  8. Задаем свой цвет для навигационной панели
  9. Применяем заданные атрибуты навигационной панели для большого заголовка
  10. Применяем те же самые атрибуты для маленького заголовка

Добавляем в сториборд еще один ViewController для создания новых задач. Над переходами поработаем позже. Сейчас разместим на нем UILabel с текстом “Create task”. Толщину шрифта для лейбла делаем Medium. Сразу под лейблом размещаем UITextView:

Заменяем содержимое Text View на “New task…” и меняем настройки шрифта:

Так же меняем цвет шрифта:

Теперь поменяем цвет фона у вью контроллера и у UITextView. Цвет фона для них будет единым

Сейчас вью контроллер должен выглядеть таким образом:

Открываем панель атрибутов Text View и меняем цвет параметра tint на белый, что бы курсов для ввода текста был белым:

Назначаем View Controller делегатом для методов протокола UITextViewDelegate:

Теперь поработаем над размещением лейбла и текстового представления на экране. Выделяем оба элемента и вызываем меню Embed In, что бы поместить их в Stack View:

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

Устанавливаем высоту стека в соответствии с высотой экрана: зажимаем клавишу Ctrl и двигаем курсор от StackView на ViewController. Из контекстного меню выбираем пункт Equal Hights:

Кликаем два раза по выставленному констрейнту и в открывшемся меню устанавливаем коэффициент равный 0.4:

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

При помощи констрейнта устанавливаем высоту лейбла, равную 25 поинтам

Для контента TextView необходимо выставить приоритет по вертикали равный 245. Для этого выделяем TextView и в панели справа открываем Size Inspector, что бы выставить соответствующее значение:

Из библиотеки объектов выбираем самую обычную кнопку:

И размещаем в самом низу экрана:

Открываем атрибуты кнопки, меняем стиль на Default, название на Cancel, а так же меняем цвет и шрифт:

Цвет фона для кнопки делаем белым:

Размещаем над кнопкой Segmented Control с тремя сегментами. Каждый сегмент будет отвечать за уровень важности добавляемой задачи. Меняем название сегментов в соответствии с уровнем. Цвет выделенного сегмента делаем красным:

Объединяем Segmented Control с кнопкой в один вертикальный стек:

Устанавливаем констрейнты для стека по 16 поинтов слева и права и 0 поинтов снизу:

Делаем копию кнопки Cancel и меняем её название на Done:

Объединяем обе кнопки в горизонтальный стек:

 

Добавляем констрейнт ширины для кнопки Cancel размером >= 70 поинтов:

Констрент горит красным, это связано с конфликтом приоритетов выставленной ширины для кнопок. Что бы исправить это, выделяем кнопку Done и в панели Size Inspector задаем приоритет ширины равный 245:

Таким образом ширина для кнопки Done будет автоматически подстраиваться под ширину экрана, занимая все оставшееся место.

В Identity Inspector скруглим углы для кнопок Cancel и Done добавив ключ cornerRadius со значением 8:

Возвращаемся к Table View Controller и размещаем справа в Navigation Bar кнопку Bar Button Item. Меняем тип с Custom на Add и цвет на белый:

При помощи сегвея с типом Present Modally делаем переход от кнопки на вью контроллер для добавления новой задачи. Присваиваем сегвею идентификатор newTask:

Добавляем в проект новый класс NewTaskViewController на основе UIViewController и связываем его с вью контроллером для добавления новой задачи:

Последовательно создаем аутлеты для второго экрана


@IBOutlet var taskTextView: UITextView!
@IBOutlet var prioritySegmentedControl: UISegmentedControl!
@IBOutlet var doneButton: UIButton!
@IBOutlet var bottomConstraint: NSLayoutConstraint!

 

Последний аутлет создаем для нижнего констрейнта стека, в котором расположены кнопки и Segmented Control Он нам понадобится для того, что бы мы могли поднять все эти элементы интерфейса над клавиатурой в момент её появления:

Что бы выбрать констрейнт, нужно выделить сам стек.

Создаем экшины для кнопок Done и Cancel:


@IBAction func doneButtonPressed() {}
@IBAction func cancelButtonPressed() {}

 

По умолчанию кнопка Done должна быть скрыта:


override func viewDidLoad() {
    super.viewDidLoad()
    doneButton.isHidden = true
}

 

Мы будем отображать её после того, как пользователь начнет вносить данные в текстовое представление.

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


@IBAction func cancelButtonPressed() {
	dismiss(animated: true)
}

 

До тех пор, пока мы не интегрируем в проект базу, данных действие по нажатию на кнопку Done будет таким же:


@IBAction func doneButtonPressed() {
	dismiss(animated: true)
}

 

 

Работа с клавиатурой

Мы будем поднимать элементы интерфейса над клавиатурой в момент её появления. Реализуем для этого приватный метод keyBoardWillShow


// MARK: - Private Methods
extension NewTaskViewController {
    @objc private func keyboardWillShow(with notification: Notification) {

    }
}

 

Сам метод мы помечаем ключевым словом @objc так как он будет вызываться через селектор наблюдателя. Наблюдатель будет вызывать наш метод при каждом появлении клавиатуры. Для этого его необходимо зарегистрировать в методе viewDidLoad:


NotificationCenter.default.addObserver(
	self,
	selector: #selector(keyboardWillShow),
	name: UIResponder.keyboardWillShowNotification,
	object: nil
)

 

  • В первый параметр мы передали self. Это значит, что действие, которое нужно будет выполнять при каждом появлении клавиатуры, должно быть реализован в этом же классе.
  • Само действие будет реализовано в методе keyboardWillShow. Именно его название мы и передаем во второй параметр через селектор. Все методы, передаваемые через селектор должны помечаться ключевым словом @objc.
  • В третьем параметре мы определяем событие, за которым и должен следить регистрируемый наблюдатель.
  • Через последний параметр можно передавать различные объекты, но нам этого не требуется, поэтому мы просто передаем туда nil.

Нам нужно будет вызывать клавиатуру автоматически при переходе на экран добавления новой задачи. Для этого нужно вызвать метод becomeFirstResponder() у аутлета taskTextView. Сделаем это в отдельном приватном методе setupTextView(). И в этом же методе зададим белый цвет для текста:


private func setupTextView() {
	taskTextView.becomeFirstResponder()
	taskTextView.textColor = .white						  
}

 

Сам метод вызываем во viewDidLoad():


override func viewDidLoad() {
        super.viewDidLoad()
        setupTextView()
	    ...
	}

 

Теперь поработаем над методом keyBoardWillShow:


// MARK: - Keyboard
extension NewTaskViewController {
    @objc private func keyboardWillShow(with notification: Notification) {
        let key = UIResponder.keyboardFrameEndUserInfoKey // 1
        
        guard let keyboardFrame = notification.userInfo?[key] as? CGRect else { return } // 2
        bottomConstraint.constant = keyboardFrame.height // 3
        
        UIView.animate(withDuration: 0.3) { // 4
            self.view.layoutIfNeeded() // 5
        }
    }
}

 

  1. Объявляется константа key которая содержит UIResponder.keyboardFrameEndUserInfoKey. Свойство keyboardFrameEndUserInfoKey содержит название ключа для использования в словаре userInfo, который передается с уведомлением о появлении или изменении размера клавиатуры. Этот ключ имеет значение типа CGRect, который определяет размер и положение клавиатуры на экране в момент ее отображения или изменения размера.
  2. Используя полученный ключ, извлекаем размер клавиатуры из словаря и приводим его к типу CGRect
  3. Устанавливаем значение для нижнего констрейнта равное высоте клавиатуры
  4. Что бы процесс смещения элементов интерфейса происходил анимированно, используем метод animate(withDuration:)
  5. Метод layoutIfNeeded() запускает процесс смещения элементов в соответствии с новыми значениями нижнего констрейнта.

 

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

Для работы с UITextView подписываем класс под протокол UITextViewDelegate и реализуем метод tetxViewDidChangeSelection, который вызывается при взаимодействии с текстовым полем:


// MARK: - Text view delegate
extension NewTaskViewController: UITextViewDelegate {
    func textViewDidChangeSelection(_ textView: UITextView) {
        if doneButton.isHidden { // 1
            textView.text.removeAll() // 2
            doneButton.isHidden = false // 3
            
            UIView.animate(withDuration: 0.3) { // 4
                self.view.layoutIfNeeded() // 5
            }
        }
    }
}

 

  1. Все действия в этом методе мы выполняем только при добавлении новой задачи. Кнопка Done для новых задач по умолчанию скрыта
  2. Тапая по текстовому представлению, автоматически удаляем заранее прописанный плейсхолдер.
  3. Как только начинается процесс редактирования текста, появляется кнопка Done
  4. Сам процесс появления кнопки будет происходить анимированно
  5. Запускаем процесс перестановки элементов интерфейса

 

Работа с Core Data

Модель данных

Для сохранения и восстановления данных, а так же для их редактирования и удаления мы будем использовать нативную базу данных Core Data. Создадим для начала в проекте новую директорию Models для модели данных. Для этого можно воспользоваться сочетанием горячих клавиш Option + Command + N. Теперь добавим в этот каталог новый файл, воспользовавшись сочетанием клавиш Command + N. В окне добавления нового файла выбираем шаблон Data Model из раздела Core Data:

Даем файлу название проекта JustDoIt и добавляем в него новую Entity (сущность) под называнием Task:

Entity – это ссылочный тип данных для работы с моделями базы данных. Теперь определим набор полей нашей модели, отвечающих за название задачи, её приоритет и дату добавления: titleprioritydate:

У свойств title и priority снимаем параметр Optioanal. Т.е. эти свойства будут обязательными для инициализации при создании экземпляра модели:

 

 

Storage Manager

Создаем еще одну директорию под названием Services и добавляем в неё новый файл StorageManager.swift. В этом файле мы реализуем одноименный класс для работы с базой данных, который будет определен как синглтон. Фрйемворк Foundation заменяем на CoreData:


import CoreData

class StorageManager {
    static let shared = StorageManager()
    
    private init() {}
}

 

Создадим точку входа в базу данных:


import CoreData

class StorageManager {
    static let shared = StorageManager()
    
    // MARK: - Core Data stack
    private let persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "JustDoIt")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()
    
    private var viewContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }
    
    private init() {}
}

 

Реализуем метод для сохранения контекста:


// MARK: - Core Data Saving support
    func saveContext() {
        if viewContext.hasChanges {
            do {
                try viewContext.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }

 

Для отображения списка задач в табличном представлении основного экрана мы будем использовать класс NSFetchedResultsController. Данный класс представляет собой контроллер, для отображения записей из Core Data, сделанных по определенному запросу. Он позволяет следить за изменениями в данных и обновлять таблицу автоматически, без необходимости ручного обновления каждый раз, когда данные изменяются. Реализуем метод, который будет возвращать экземпляр NSFetchedResultsController с заданными параметрами:


    func getFetchedResultsController(entityName: String, keyForSort: String) -> NSFetchedResultsController<NSFetchRequestResult> { // 1
        let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName) // 2
        let sortDescriptor = NSSortDescriptor(key: keyForSort, ascending: true) // 3
        
        fetchRequest.sortDescriptors = [sortDescriptor] // 4
        
        let fetchResultsController = NSFetchedResultsController( // 5
            fetchRequest: fetchRequest,
            managedObjectContext: viewContext,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        return fetchResultsController // 6
    }

 

  1. Метод принимает два параметра: название сущности и параметр для сортировки записей. Это позволит определять по какому полю делать сортировку задач
  2. Здесь мы создаем запрос для выборки данных из базы, используя класс NSFetchRequestNSFetchRequest это класс, который используется для создания запросов на получение данных из контекста Core Data. Контекст в свою очередь представляет собой временную область в оперативной памяти устройства, которая содержит информацию о состоянии объектов и отслеживает все изменения в базе данных. Т.е. для того что бы внести какие-либо изменения в базу данных, эти изменения сначала делаются в оперативной памяти, называемой контекстом, от куда затем сливаются в базу. То же самое касается выборки данных. Сначала данные заливаются в контекст и уже от туда делается их выборка для отображения в интерфейсе.
  3. Здесь создается объект NSSortDescriptor с параметрами, используемыми для сортировки данных
  4. Дальше этот объект добавляется в массив fetchRequest.sortDescriptors как критерий сортировки, таким образом данные будут отсортированы по атрибуту keyForSort в порядке возрастания, т.к. параметр ascending принимает значение true.
  5. Создание экземпляра класса NSFetchedResultsController на основе, созданного ранее запроса и параметров сортировки.
  6. В конце функция возвращает созданный экземпляр NSFetchedResultsController

Реализуем метод для создания и сохранения задачи в базе данных:


    func saveTask(withTitle title: String, andPriority priority: Int16) {
        let task = Task(context: viewContext) // 1
        task.title = title
        task.priority = priority
        task.date = Date()
        saveContext() // 2
    }

 

  1. Создаем экземпляр сущности Task и присваиваем свойствам экземпляра значения из параметров метода
  2. Вызываем метод saveContext() для внесения изменений в базу данных

Метод для удаления задачи:


    func delete(task: Task) {
        viewContext.delete(task)
        saveContext()
    }

 

 

Сохранение задачи в базе данных

Теперь когда у нас есть метод saveTask мы можем вызывать его по нажатию на кнопку Done на экране добавления новой задачи. Переходим в класс NewTaskViewController дорабатываем метод doneButtonPressed:


    @IBAction func doneButtonPressed() {
        guard let title = taskTextView.text, !title.isEmpty else { return } // 1
        let priority = Int16(prioritySegmentedControl.selectedSegmentIndex) // 2
        StorageManager.shared.saveTask(withTitle: title, andPriority: priority) // 3
        dismiss(animated: true)
    }

 

  1. Извлекаем опциональное значение из свойства text и проверяем не является ли оно пустой строкой
  2. Задаем приоритет на основе выбранного сегмента
  3. Далее вызываем метод saveTask и передаем в его параметры заголовок для задачи и выставленный приоритет

 

 

Отображение списка задач на основном экране

Переходим в класс TaskListViewController, чтобы поработать над отображением списка задач. Для начала создадим экземпляр класса NSFetchResultController на основе которого мы и будет отображать список с заданными параметрами:


    private var fetchedResultsController = StorageManager.shared.getFetchedResultsController(
        entityName: "Task",
        keyForSort: "date"
    )

 

Наш fetchedResultsController будет делать выборку задач из базы данных и сортировать их по дате добавления. Реализуем для этого приватный метод fetchTasks():


    private func fetchTasks() {
        do {
            try fetchedResultsController.performFetch()
        } catch {
            print(error)
        }
    }

 

Метод fetchResultsController.performFetch() выполняет запрос к базе данных и загружает данные, соответствующие критериям запроса. Так как метод выкидывает throws, то его вызов осуществляется через ключевое слово try и оборачивается в блок do-catch, для отлова ошибок, если что-то пойдет не так.

Сам метод fetchTasks() вызываем из viewDidLoad:


    override func viewDidLoad() {
        super.viewDidLoad()
        fetchTasks()
    }

 

Пришла пора поработать над методами протокола UITableViewDataSourse для отображения данных в интерфейсе:


// MARK: - Table View Data Source
extension TaskListViewController {
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchedResultsController.fetchedObjects?.count ?? 0
    }
}

 

fetchedResultsController.fetchedObjects – это массив с объектами, которые были загружены из базы данных при последнем вызове метода performFetch(). Этот массив содержит результаты запроса, которые можно использовать для отображения или обновления данных в табличном представлении.

Теперь реализуем второй обязательный метод протокола:


// MARK: - Table View Data Soutce
extension TaskListViewController {
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchedResultsController.fetchedObjects?.count ?? 0
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        guard let task = fetchedResultsController.object(at: indexPath) as? Task else { return cell } // 1

		// Далее надо поработать над контентом ячейки. Вынесем эту работу в отдельный приватный метод

		return cell
    }
}

 

  1. Вызов метода fetchedResultsController.object(at:) возвращает объект с типом NSManagedObject из массива fetchedResultsController.fetchedObjects, который мы затем приводим к типу Task с помощью оператора as?.

Выносим работу по отображению контента ячейки в отдельный приватный метод:


    private func setContentForCell(with task: Task?) -> UIListContentConfiguration {
        var content = UIListContentConfiguration.cell()
        
        content.textProperties.font = UIFont(
            name: "Avenir Next Medium", size: 23
        ) ?? UIFont.systemFont(ofSize: 23)
        
        content.textProperties.color = .darkGray
        content.text = task?.title

        return content
    }

 

Нам осталось присвоить результат работы данного метода свойству contentConfiguration в методе cellForRowAt:


    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        let task = fetchedResultsController.object(at: indexPath) as? Task
        cell.contentConfiguration = setContentForCell(with: task)
        return cell
    }

 

 

NSFetchResultsControllerDelegate

Для работы с контентом табличного представления, необходимо поработать с методами протокола NSFetchResultsControllerDelegate. Данный протокол используется для получения уведомлений об изменениях в данных, которые управляются объектом NSFetchedResultsController. Этот протокол определяет методы, которые могут быть использованы для обновления пользовательского интерфейса или другой логики приложения при изменении данных. Для доступа к протоколу в класс необходимо импортировать Core Data: import CoreData. Теперь мы можем подписать класс под протокол и реализовать все необходимые методы:


// MARK: - NSFetchResultsControllerDelegate
extension TaskListViewController: NSFetchedResultsControllerDelegate {
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { // 1
        tableView.beginUpdates()
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {  // 2      
        switch type {
        case .insert:
			guard let newIndexPath = newIndexPath else { return }
            tableView.insertRows(at: [newIndexPath], with: .automatic)
        case .delete:
			guard let indexPath = indexPath else { return }
            tableView.deleteRows(at: [indexPath], with: .automatic)
        default: break
        }
    }

    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { // 3
        tableView.endUpdates()
    }
}

 

  1. Метод вызывается перед тем, как NSFetchedResultsController начнет обновлять данные. Он предназначен для того, чтобы приложение могло начать обновлять пользовательский интерфейс до того, как изменения будут произведены. Что бы подготовить табличное представление к обновлению мы вызываем у tableView метод beginUpdates().
  2. Метод вызывается каждый раз, когда NSFetchedResultsController обнаруживает изменения в данных. Этот метод предоставляет информацию о типе изменения (например, вставка, удаление, изменение или перемещение), а также индексы для объектов, которые были изменены. Метод используется для того, чтобы вызвать соответствующие методы UITableView, такие как insertRows(at:with:)deleteRows(at:with:)reloadRows(at:with:) или moveRow(at:to:), чтобы обновить таблицу в соответствии с изменениями данных. В данном случае мы пока обрабатываем кейсы для добавления и удаления задачи.
  3. Метод вызывается один раз после того, как все изменения, которые были обнаружены NSFetchedResultsController, были обработаны методом controller(_:didChange:at:for:newIndexPath:). Этот метод используется, чтобы завершить обновление интерфейса пользователя и произвести любые другие необходимые действия после обновления данных. Чтобы завершить анимацию и обновить табличное представление мы вызываем у tableView метод endUpdates()

Для того, что бы методы протокола вызывались в нужный момент времени класс необходимо назначить делегатом протокола. Делаем это в методе viewDidLoad


fetchedResultsController.delegate = self

 

 

Удаление задачи

Для создания пользовательских действий при взаимодействии со строками таблицы мы реализуем метод trailingSwipeActionsConfigurationForRowAt протокола UITextFieldDelegate. Меню пользовательских действий отобразится при свайпе по строке справа на лево:


// MARK: - Table View Delegate
extension TaskListViewController {
    override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, _ in // 1
            if let task = self.fetchedResultsController.object(at: indexPath) as? Task { // 2
                StorageManager.shared.delete(task: task) // 3
            }
        }
        
        deleteAction.image = UIImage(systemName: "trash") // 4
        
        return UISwipeActionsConfiguration(actions: [deleteAction]) // 5
    }
}

 

  1. Создаем экземпляр класса UIContextualAction для создания пользовательского действия по удалению задачи с параметром destructive. Благодаря этому параметру кнопка Delete будет красного цвета.
  2. Извлекаем экземпляр удаляемой задачи из массива со списком всех задач по индексу выбранной строки
  3. Вызываем метод delete класса StorageManager для удаления задачи из базы данных
  4. Присваиваем для созданного действия системное изображение в виде корзины
  5. Помещаем созданное пользовательское действие в список доступных действий.

 

 

Редактирование задачи

Для начала реализуем возможность редактирования задачи в базе данных. Идем для этого в класс StorageManager и добавим реализуем еще метод edit(task:, with: and:)


    func edit(task: Task, with newTitle: String, and priority: Int16) {
        task.title = newTitle
        task.priority = priority
        saveContext()
    }

 

Метод принимает три параметра:

  1. task – определяет редактируемую задачу
  2. newTitle – принимает новый заголовок для задачи
  3. priority – определяет новый приоритет

Теперь переходим в класс NewTaskViewController, чтобы доработать его функционал для редактирования ранее созданных задач. Объявляем для начала новое публичное опциональное свойство: var task: Task?.

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


    @IBAction func doneButtonPressed() {
        guard let title = taskTextView.text, !title.isEmpty else { return }
        let priority = Int16(prioritySegmentedControl.selectedSegmentIndex)
        if let task = task { // 1
            StorageManager.shared.edit(task: task, with: title, and: priority)
        } else { // 2
            StorageManager.shared.saveTask(withTitle: title, andPriority: priority)
        }
        dismiss(animated: true)
    }

 

  1. Если свойство task будет инициализировано, значит мы находимся в режиме редактирования задачи и вызываем метод edit
  2. Если свойство task имеет nil, значит мы находимся в режиме добавления новой задачи и вызываем метод saveTask

Так же необходимо доработать метод setupTextView(). В том случае если экран открыт в режиме редактирования существующей задачи, то в текстовом поле вместо заготовленного плейсхолдера нужно отобразить заголовок текущей задачи:


    private func setupTextView() {
        taskTextView.becomeFirstResponder()
        if let task = task {
            taskTextView.text = task.title
            prioritySegmentedControl.selectedSegmentIndex = Int(task.priority)
        } else {
            doneButton.isHidden = true
        }
    }

 

Не забываем удалить строку doneButton.isHidden = true из метода viewDidLoad

Переходим в сториборд и добавляем новый сегвей от ячейки с идентификатором “editTask”:

Таким образом переход на второй экран будет осуществляться двумя разными способами.

Теперь переходим в класс TaskListViewController чтобы поработать над переходом по сегвею editTask. Для начала реализуем еще один метод протокола UITextFieldDelegate:


    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // 1
        tableView.deselectRow(at: indexPath, animated: true) // 2
        let task = fetchedResultsController.object(at: indexPath) as? Task // 3
        performSegue(withIdentifier: "editTask", sender: task) // 4
    }

 

  1. Метод вызывается каждый раз, когда пользователь тапает по строке табличного представления
  2. Снимаем выделение со строки, после того как она будет выделена
  3. Извлекаем экземпляр выбранной задачи из массива со списком всех задач по индексу выбранной строки
  4. Вызываем метод performSegue, что бы инициировать переход по сегвею с идентификатором editTask и передаем в параметр sender выбранную задачу.

Для передачи выбранной задачи на экран редактирования переопределим метод prepare:


    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "editTask" { // 1
            guard let newTaskVC = segue.destination as? NewTaskViewController else { return } // 2
            newTaskVC.task = sender as? Task // 3
        }
    }

 

  1. Код будет выполнен только в том случае, если переход осуществляется по сегвею editTask
  2. Создаем экземпляр класса NewTaskViewController
  3. Инициализируем свойство task текущей задачей, которую мы извлекаем из параметра sender. В этом параметре хранится тот объект, который мы передали в метод performSegue в тот же самый параметр.

Для обновления интерфейса после редактирования задачи необходимо реализовать дополнительный кейс update в методе controller(_:, didChange:, at:, for:, newIndexPaht:) протокола NSFetchedResultsControllerDelegate:


...
case .update:
	guard let indexPath = indexPath else { return }
	tableView.reloadRows(at: [indexPath], with: .automatic)
default: break

 

 

Помечаем задачу, как выполненную

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

Обратите внимание на то, что данные свойство не должно быть опциональным.

После внесения изменений в модель данных приложение нужно удалить из симулятора и установить его заново.

Давайте доработаем метод возвращающий NSFetchedResultsController таким образом, что бы в нем содержались параметры для сортировки не только по дате добавления, но еще и по полю isComplete. Открываем класс StorageManager и переходим к методу fetchedResultsController:


func fetchedResultsController(entityName: String, keysForSort: [String]) -> NSFetchedResultsController<NSFetchRequestResult> { // 1
    let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
    var sortDescriptors: [NSSortDescriptor] = [] // 2
    keysForSort.forEach { keyForSort in // 3
        let sortDescriptor = NSSortDescriptor(key: keyForSort, ascending: true)
        sortDescriptors.append(sortDescriptor)
    }
    fetchRequest.sortDescriptors = sortDescriptors
    
    let fetchResultsController = NSFetchedResultsController(
        fetchRequest: fetchRequest,
        managedObjectContext: viewContext,
        sectionNameKeyPath: nil,
        cacheName: nil
    )
    return fetchResultsController
}

 

  1. Меняем название и тип второго параметра на массив: keysForSort: [String]
  2. Создаем пустой массив с типом [NSSortDescriptor]
  3. Перебираем массив keysForSort и на основе каждого его элемента создаем экземпляр класса NSSortDescriptor с сортировкой по возрастанию. Далее добавляем каждый созданный экземпляр sortDescriptor в массив sortDescriptors

Далее необходимо доработать метод saveTask в соответствии с обновленной моделью данных:


func saveTask(withTitle title: String, andPriority priority: Int16) {
    let task = Task(context: viewContext)
    task.title = title
    task.priority = priority
    task.date = Date()
    task.isComplete = false // 1
    saveContext()
}

 

  1. Устанавливаем в качестве значения по умолчанию для поля isComplete значение false. Это значит, что вновь создаваемые задачи будут помечены как не выполненные.

Реализуем метод, позволяющий пометить задачу, как выполненную:


func done(task: Task) {
    task.isComplete.toggle()
    saveContext()
}

 

Переходим в класс TaskListViewController, что бы отрефакторить инициализацию экземпляра класса NSFetchedResultsController в соответсвии с новыми параметрами


private var getFetchedResultsController = StorageManager.shared.fetchedResultsController(
    entityName: "Task",
    keysForSort: ["isComplete", "date"]
)

 

Что бы пометить задачу, как выполненную, добавим еще одно пользовательское действие по свайпу слева на право


override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let doneAction = UIContextualAction(style: .normal, title: "Done") { _, _, isDone in // 1
        if let task = self.fetchedResultsController.object(at: indexPath) as? Task {
            StorageManager.shared.done(task: task)
        }
        isDone(true) // 2
    }
    
    doneAction.image = UIImage(systemName: "checkmark") // 3
    doneAction.backgroundColor = #colorLiteral()
    
    return UISwipeActionsConfiguration(actions: [doneAction])
}

 

  1. В качестве третьего свойства в блоке замыкания выступает ещё одно замыкание isDone, которое захватывает логическое свойство. Его необходимо использовать для того, что бы скрыть меню пользовательских действий после нажатия на кнопку Done
  2. Здесь мы передаем в замыкание isDone значение true, что бы скрыть меню пользовательских действий после того, как кнопка Done была нажата
  3. Присваиваем для созданного действия системное изображение в виде чекмарка

Что бы пометить задачу как выполненную будем перечеркивать её название. Реализуем для этого приватный метод strikeThrough:


    private func strikeThrough(string: String, _ isStrikeThrough: Bool) -> NSAttributedString {
        isStrikeThrough
            ? NSAttributedString(
                string: string,
                attributes: [
                    NSAttributedString.Key.strikethroughStyle : NSUnderlineStyle.double.rawValue
                ]
            )
            : NSAttributedString(
                string: string,
                attributes: [NSAttributedString.Key.strikethroughStyle : 0]
            )
    }

 

Метод возвращает перечеркнутую строку в том случае, если второй параметр принимает значение true. Присвоим результат работы метода свойству attributedText для контента табличного представления в методе setContentForCell:


content.textProperties.color = .darkGray
content.text = task?.title
content.attributedText = strikeThrough(string: task?.title ?? "", task?.isComplete ?? false)

 

Когда задача помечается как выполненная, её необходимо перемещать в конец списка. Реализуем для этого дополнительный кейс move в методе controller(_:, didChange:, at:, for:, newIndexPaht:) протокола NSFetchedResultsControllerDelegate:


case .move:
	guard let indexPath = indexPath else { return } // 1
	guard let newIndexPath = newIndexPath else { return } // 2
	let cell = tableView.cellForRow(at: indexPath) // 3
default: break

 

  1. Извлекаем опциональное значение из параметра indexPath. Данный параметр содержит текущий индекс строки.
  2. Извлекаем опциональное значение из параметра newIndexPath. Данный параметр содержит новый индекс который получит строка, после того, как она переместится в конец списка.
  3. Инициализируем экземпляр текущей ячейки табличного представления, что бы задать ей свои настройки конфигурации.

Что бы завершить работу с этим кейсом далее необходимо получить объект задачи по индексу текущей строки. Реализуем для этого еще один вспомогательный метод:


    private func getTask(at indexPath: IndexPath?) -> Task? {
        if let indexPath = indexPath {
            return fetchedResultsController.object(at: indexPath) as? Task
        }
        return nil
    }

 

Возвращаемся к кейсу move и продолжаем:


case .move:
	guard let indexPath = indexPath else { return }
	guard let newIndexPath = newIndexPath else { return }
	let cell = tableView.cellForRow(at: indexPath)
	let task = getTask(at: newIndexPath) // 1
	cell?.contentConfiguration = setContentForCell(with: task) // 2
	tableView.moveRow(at: indexPath, to: newIndexPath) // 3
default: break

 

  1. Инициализируем экземпляр задачи в соответствии с новым индексом, который будет присвоен строке после её перемещения в конец списка.
  2. Задаем параметры конфигурации для контента ячейки.
  3. Перемещаем строку в конец списка и делаем это анимировано.

Итоговый результат:

 

 

Репозиторий проекта

Содержание