Пишем фотоприложение для iOS с нуля: большой туториал
Хотите узнать, как сделать упрощенную версию Instagram? В этой статье мы создадим фотоприложение, с помощью которого вы сможете авторизоваться, добавлять фотографии и публиковать их в ленту.
Для работы нам понадобится среда разработки XCode. Код проекта лежит в репозитории на Github.
Обзор используемых библиотек
Swinject
Swinject является одним из самых популярных фреймворков для управления зависимостями на iOS.
Наиболее распространено использование вместе со сторибордами и расширением SwinjectStoryboard, но мы работаем с View – слоем исключительно в коде, поэтому не будем использовать дополнительные расширения к этому фреймворку.
Суть в следующем:
- каждую зависимость (сервис/провайдер/что угодно) мы регистрируем в контейнере, описывая в классе-наследнике Assembly, как мы получаем экземпляр объекта-зависимости;
- когда нам нужна какая-либо зависимость, например, нам нужно передать в конструктор презентера какой-то сервис как зависимость, мы запрашиваем эту зависимость в контейнере.
PluggableAppDelegate
Работая над разными проектами, можно часто наблюдать перегруженный AppDelegate.swift. Он грязный, с беспорядочными инициализациями, настройками разных библиотек, используемых в проекте. Когда открываешь такой AppDelegate в первый раз сразу хочется его закрыть и больше не видеть.
При использовании PluggableAppDelegate вы разделяете AppDelegate на небольшие части, и в каждой такой части вы описываете небольшую функциональность.
Например:
- отдельный ApplicationService (логически отделенный кусок логики AppDelegate) для инициализации и конфигурирования пушей;
- отдельный ApplicationService для конфигурирования аналитики;
- отдельный ApplicationService для конфигурирования логирования;
- и т.д.
Затем в AppDelegate вы просто указываете, какие ApplicationService подключать.
Rx
RxSwift — FRP-фреймворк. Добавляет возможность "реактивного стиля" программирования. Реактивное программирование — парадигма программирования, ориентированная на потоки данных и распространение изменений. Будем использовать Rx для биндинга UI элементов, таких как коллекции и текстовые поля.
В этом нам поможет расширение RxCocoa, предназначенное для работы с UI в реактивном стиле.
Firebase
Firebase — это набор облачных сервисов от Google, такой BaaS.
На самом деле Firebase предоставляет много возможностей, таких как:
- аналитика;
- аутентификация пользователей;
- база данных (облачная и/или локальная);
- хранилище;
- машинное обучение;
- отладка и статистика по неполадкам;
- производительность;
- инструменты доставки приложения и тестирования.
Мы используем лишь несколько сервисов: облачную бд, хранилище для картинок наших публикаций, работу с регистрацией и авторизацией.
Отдельно хочется выделить еще одну библиотеку, подключаемую в этом блоке, это FirebaseFirestoreSwift.
Это библиотека от стороннего разработчика, она позволяет нам производить преобразования в Firebase из Response в Codable и обратно.
EasyPeasy
Верстая UI в коде и используя Autolayout мы должны описывать много правил позиционирования элементов. Так вот EasyPeasy позволяет делать это кратко и элегантно.
Toast-Swift
Небольшая библиотека для отображения сообщений пользователю в UI.
SVProgressHUD
Красивая замена UIActivityIndicatorView с обильными возможностями для конфигурации внешнего вида.
Paparazzo
Удобнейший пикер изображений. Это такая мощная замена не всегда подходящему UIImagePickerController. К тому же он локализируется и настраивается с помощью встроенных тем. А еще на выходе Paparazzo отдает объект-обертку над ImageSource вместо UIImage, что позволяет нам иметь больше возможностей, например, при отображении превью выбранного изображения (установка размера изображения, заглушке). ImageSource — это такая абстракция над UIImage.
Кроме прочего, Paparazzo позволяет поворачивать, обрезать изображение после выбора из галереи или снимка с камеры, а также применять фильтры на изображения. Фильтрами мы пользоваться в рамках этого приложения не будем, но все равно приятно, что есть запас такой функциональности.
Также есть возможность множественного выбора изображений, отображения предварительно выбранных изображений.
Начинаем!
Создаем пустой проект, выбираем Single View App. Для установки всех необходимых библиотек интегрируем CocoaPods:
gem install cocoapods
Дальше переходим в папку проекта. Инициализируем рабочее пространство для нашего приложения:
pod init pod install
Подключаем нужные библиотеки
Теперь в директории проекта появился файл с ProjectName.xcworkspace, открываем его. Находим в структуре проекта файл Podfile и начинаем заполнять нужными нам библиотеками:
platform :ios, '11.0' target 'Avitogram' do use_frameworks! #core pod 'Swinject', :git => 'https://github.com/Swinject/Swinject.git' pod ‘PluggableAppDelegate’ #firebase services pod 'Firebase/Analytics' #ui pod 'EasyPeasy' end
Первая часть — core — наработанный набор библиотек, ускоряющих и упрощающих разработку. Здесь подключаются библиотеки, несущие основную функциональность приложения.
Вторая часть — firebase services. В качестве сервиса для аналитики был выбран Firebase, позволяющий отслеживать активность пользователей и собирать по ним аналитику. Здесь же подгружаются все необходимые библиотеки от Google.
Третья часть — ui — библиотека для верстки и работы с медиафайлами, в которой отображаются уведомления. Если ищете альтернативы UIActivityIndicatorView, то вот она.
Заполним Podfile и снова идем обратно в терминал: команда pod install добавляет все указанные нами библиотеки.
Базовая структура проекта
Генерация модулей
Поскольку в качестве архитектурного паттерна выбрано некое подобие MVP, используем кодогенерацию модулей с помощью библиотеки Generamba. Generamba позволяет генерировать файлы классов и автоматически класть в определенные папки и группы проекта. Устанавливаем библиотеку и запускаем её:
gem install generamba generamba setup
Отвечаем на все вопросы конфигуратора:
$ generamba setup The company name which will be used in the headers: Proglib The name of your project is Avitogram. Do you want to use it? (yes/no) yes The project prefix (if any): The path to a .xcodeproj file of the project is 'Avitogram.xcodeproj'. Do you want to use it? (yes/no) yes Select the appropriate target for adding your MODULES (type the index): gramvito 1. AvitogramTests 2. AvitogramUITests Are you using unit-tests in this project? (yes/no) Do you want to add all your modules by one path? (yes/no) yes Do you want to use the same paths for your files both in Xcode and the filesystem? (yes/no) The default path for creating new modules (in Xcode groups): Avitogram/Source/Modules The default path for creating new modules (in the filesystem): Avitogram/Source/Modules Are you using Cocoapods? (yes/no) yes The path to a Podfile is 'Podfile'. Do you want to use it? (yes/no) yes Are you using Carthage? (yes/no) no Do you want to add some well known templates to the Rambafile? (yes/no) no
По окончании установки генерируется Rambafile, в который пропишем все конфигурации для генерации кода. Файл лежит в корневой директории проекта. Должен получиться файл следующего содержимого:
### Headers settings company: Proglib ### Xcode project settings project_name: Avitogram xcodeproj_path: Avitogram.xcodeproj ### Code generation settings section # The main project target name project_target: Avitogram # The file path for new modules project_file_path: Avitogram/Source/Modules # The Xcode group path to new modules project_group_path: Avitogram/Source/Modules ### Dependencies settings section podfile_path: Podfile ### Templates templates: #- {name: local_template_name, local: 'absolute/file/path'} #- {name: remote_template_name, git: 'https://github.com/igrekde/remote_template'} #- {name: catalog_template_name}
Нас интересуют только последние строки, где мы указываем название нашего MVP шаблона модуля и откуда его качать. В качестве шаблона используем нашу наработку:
templates: - {name: swifty_mvp, git: 'https://github.com/spase84/swift_mvp'}
Сохраняем файл и устанавливаем шаблоны:
generamba template install
Generamba подтянула шаблон и готова генерировать заготовки кода под модули. Сделаем первый. Пусть это будет Guest модуль — будем показывать его неавторизованным пользователям.
Снова идем в терминал:
generamba gen Guest swifty_mvp
Документацию по генерамбе можно найти в ее репозитории на гитхабе.
Там же есть описание, как подготавливать свои темплейты для модулей. А у нас готов гостевой модуль, его мы показываем всем пользователям при первом запуске.
Дробим AppDelegate
Первый модуль есть, но это пока только заготовка.
Более того, этот generamba-шаблон модуля подразумевает наличие некоторых протоколов в проекте, например, протокола Navigator, но об этом дальше.
Сейчас займёмся дроблением AppDelegate на отдельные сервисы.
Зачем это нужно? Это наработанная со временем техника разделения конфигурирования разных библиотек при запуске приложения. Зачем мешать Firebase, работу с пуш-уведомлением, конфигурирование логгеров и много чего еще. в одном методе AppDelegate? В конце концов, можно же использовать принцип разделения интерфейса.
В этом нам поможет библиотека PluggableAppDelegate.
В нашем случае мы создадим RootApplicationService для настройки рут-контроллера для window.
Также создадим GoogleApplicationService, в котором произведём инициализацию библиотек Firebase.
Код самого AppDelegate.swift будет теперь выглядеть так:
import UIKit import PluggableAppDelegate @UIApplicationMain class AppDelegate: PluggableApplicationDelegate { private var rootApplicationService = RootApplicationService() override var services: [ApplicationService] { return [ rootApplicationService, GoogleApplicationService() ] } func reloadRootScreen() { rootApplicationService.reloadStartScreen() } }
Зарегистрировали сервисы и разгрузили сам AppDelegate от кучи кода, смешанного в одном методе.
DI-контейнер
Для управления зависимостями будем использовать Swinject.
Настало время написать код в только что созданном RootApplicationService.
Подключаем Swinject.
Добавляем приватные константы assembler и assemblies.
Позже в assemblies мы будем добавлять assembly каждого нового модуля.
Пока добавим туда лишь assembly нашего единственного Guest модуля.
Отлично! Дальше в didFinishLaunchingWithOptions укажем ассемблеру, что нужно применить все assemblies: это зарегистрирует наши зависимости для каждого модуля. Правда, мы еще ни одной не написали, но скоро это исправим.
Напишем публичный метод для перезагрузки экрана приложения — это нужно, чтобы не держать авторизованного пользователя в стеке Guest модуля.
Пока оставим здесь только загрузку гостевого модуля, позднее вернемся сюда и напишем логику определения, какой модуль грузить.
import Foundation import PluggableAppDelegate import Swinject final class RootApplicationService: NSObject, ApplicationService { private var application: UIApplication! private let assembler = Assembler([]) private let assemblies: [Assembly] = [ GuestAssembly() ] func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { self.application = application assembler.apply(assemblies: assemblies) self.reloadStartScreen() return true } public func reloadStartScreen() { DispatchQueue.main.async { if UserServiceImpl.currentUser.isGuest || UserServiceImpl.isFirstLaunch { self.load(moduleType: .guest, for: self.application) } else { self.load(moduleType: .home, for: self.application) } } } // MARK: - private private enum LoadingModuleType { case guest, home } private func load(moduleType: LoadingModuleType, for application: UIApplication) { var rootVC = UIViewController() rootVC.view.backgroundColor = .SLBlack switch moduleType { case .home: if let vc = assembler.resolver.resolve(HomeViewType.self) as? HomeViewController { let navigation = UINavigationController(rootViewController: vc) navigation.navigationBar.tintColor = .white navigation.navigationBar.barTintColor = .SLBlackTwo navigation.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] rootVC = navigation } case .guest: if let vc = assembler.resolver.resolve(GuestViewType.self) as? GuestViewController { rootVC = vc } } UserServiceImpl.isFirstLaunch = false if let delegate = application.delegate as? AppDelegate { if nil == delegate.window { delegate.window = UIWindow() } delegate.window?.rootViewController = rootVC delegate.window?.makeKeyAndVisible() } } }
Сервисы
Поскольку у нас проект на MVP, бизнес-логику будем держать в сервисах.
Напишем первый протокол сервиса пользователя.
Добавляем группу /Source/Services и в нее файл UserService.swift
Сначала опишем поведение в протоколе:
protocol UserService { var trigger: PublishSubject<(AuthStatus, Error?)> { get } static var currentUser: User { get } static var isFirstLaunch: Bool { get set } func signIn(email: String, password: String) func signUp(email: String, password: String) func signOut() }
Подключим RxSwift и Firebase
Свойство trigger нужно нам для отправки событий о статусе авторизации пользователя, это PublishSubject из RxSwift. Добавим немного подхода «Наблюдатель» в приложение, это позволит писать меньше кода. Он реализует механизм, который позволяет объекту этого класса получать оповещения об изменении состояния других объектов и тем самым наблюдать за ними.
Добавим также Enum AuthStatus для передачи самого статуса в триггер.
Дальше пишем реализацию методов, описанных в протоколе.
Это будет, наверное, самый маленький сервис в нашем приложении.
Теперь нам нужно зарегистрировать наш сервис в DI-контейнере.
Создаем ServicesAssembly.swift в группе Services и регистрируем наш только что написанный сервис.
Теперь идем в RootApplicationService и добавляем в assemblies нашу ServicesAssembly перед guestAssembly.
import Foundation import PluggableAppDelegate import Swinject final class RootApplicationService: NSObject, ApplicationService { private var application: UIApplication! private let assembler = Assembler([]) private let assemblies: [Assembly] = [ ServicesAssembly(), GuestAssembly() ] func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { self.application = application assembler.apply(assemblies: assemblies) self.reloadStartScreen() return true } public func reloadStartScreen() { DispatchQueue.main.async { self.load(moduleType: .guest, for: self.application) } } // MARK: - private private enum LoadingModuleType { case guest } private func load(moduleType: LoadingModuleType, for application: UIApplication) { var rootVC = UIViewController() rootVC.view.backgroundColor = .SLBlack switch moduleType { case .guest: if let vc = assembler.resolver.resolve(GuestViewType.self) as? GuestViewController { rootVC = vc } } if let delegate = application.delegate as? AppDelegate { if nil == delegate.window { delegate.window = UIWindow() } delegate.window?.rootViewController = rootVC delegate.window?.makeKeyAndVisible() } } }
Firebase
Для идентификации пользователей мы используем Firebase Auth.
Давайте напишем реализацию методов регистрации и авторизации пользователя с помощью Firebase Auth.
Идем в наш UserService.swift и пишем реализацию протокола сервиса.
final class UserServiceImpl: UserService { private(set) var trigger: PublishSubject<(AuthStatus, Error?)> = PublishSubject<(AuthStatus, Error?)>() private let firebaseAuth = Auth.auth() static var currentUser: User { var user = User() if let firUser = Auth.auth().currentUser { user.id = firUser.uid user.name = firUser.displayName } return user } static var isFirstLaunch: Bool { get { return !UserDefaults.standard.bool(forKey: "isNotFirstLaunch") } set { UserDefaults.standard.set(!newValue, forKey: "isNotFirstLaunch") UserDefaults.standard.synchronize() } } func signIn(email: String, password: String) { firebaseAuth.signIn(withEmail: email, password: password) { [weak self] authResult, error in if let error = error { self?.trigger.onNext((.guest, error)) } else { switch authResult { case .some: self?.trigger.onNext((.authorized, nil)) default: break } } } } func signUp(email: String, password: String) { firebaseAuth.createUser(withEmail: email, password: password) { [weak self] authResult, error in if let error = error { self?.trigger.onNext((.guest, error)) } else { switch authResult { case .some: self?.trigger.onNext((.authorized, nil)) default: break } } } } func signOut() { do { try firebaseAuth.signOut() } catch let signOutError as NSError { debugPrint("Error signing out: %@", signOutError.localizedDescription) } } }
Свойство currentUser вычисляемое и отдает текущего пользователя, заполняя поля из объекта пользователя Firebase, если он авторизован.
Структура пользователя выглядит так:
struct User { var id: String? var name: String? var isGuest: Bool { nil == id } }
Также мы добавили свойство isFirstLaunch, говорящее нам, в первый раз запускается приложение или нет.
Его значение мы храним просто в UserDefaults. UserDefaults – это интерфейс, работающий с настройками пользователя по умолчанию. Подробнее можно прочитать по теме тут.
Теперь наш файл сервиса UserService полностью выглядит так:
import Foundation import Firebase import RxSwift enum AuthStatus { case guest, authorized } protocol UserService { var trigger: PublishSubject<(AuthStatus, Error?)> { get } static var currentUser: User { get } static var isFirstLaunch: Bool { get set } func signIn(email: String, password: String) func signUp(email: String, password: String) func signOut() } final class UserServiceImpl: UserService { private(set) var trigger: PublishSubject<(AuthStatus, Error?)> = PublishSubject<(AuthStatus, Error?)>() private let firebaseAuth = Auth.auth() static var currentUser: User { var user = User() if let firUser = Auth.auth().currentUser { user.id = firUser.uid user.name = firUser.displayName } return user } static var isFirstLaunch: Bool { get { return !UserDefaults.standard.bool(forKey: "isNotFirstLaunch") } set { UserDefaults.standard.set(!newValue, forKey: "isNotFirstLaunch") UserDefaults.standard.synchronize() } } func signIn(email: String, password: String) { firebaseAuth.signIn(withEmail: email, password: password) { [weak self] authResult, error in if let error = error { self?.trigger.onNext((.guest, error)) } else { switch authResult { case .some: self?.trigger.onNext((.authorized, nil)) default: break } } } } func signUp(email: String, password: String) { firebaseAuth.createUser(withEmail: email, password: password) { [weak self] authResult, error in if let error = error { self?.trigger.onNext((.guest, error)) } else { switch authResult { case .some: self?.trigger.onNext((.authorized, nil)) default: break } } } } func signOut() { do { try firebaseAuth.signOut() } catch let signOutError as NSError { debugPrint("Error signing out: %@", signOutError.localizedDescription) } } }
Главный экран
Пришло время добавить еще один модуль, модуль главного экрана.
Набираем в терминале уже знакомую нам команду генерации заготовки под модуль:
generamba gen Home swifty_mvp
Добавляем таблицу на главный экран для отображения наших записей и UIRefreshControl, чтобы обновлять таблицу свайпом.
private let tableView: UITableView = { let table = UITableView() table.register(PostCell.self, forCellReuseIdentifier: PostCell.identifier) table.separatorStyle = .none table.backgroundColor = .clear table.tableFooterView = UIView() table.showsVerticalScrollIndicator = false table.showsHorizontalScrollIndicator = false return table }() private let refresher: UIRefreshControl = { let refreshControl = UIRefreshControl() refreshControl.tintColor = .SLDullYellow return refreshControl }()
Создадим структуру Post следующего вида:
struct Post { var title: String = "" var createdAt: Date = Date() var userId: String? var imageName: String? var image: ImageSource? var reference: StorageReference? enum Fields: String { case id = "id" case createdAt = "created_at" case userId = "user_id" case imageName = "image_name" } enum ValidationError: Error { case titleEmpty case imageEmpty case userIdEmpty var localizedDescription: String { switch self { case .titleEmpty: return "error_create_post_empty_title".localized.firstUppercased case .imageEmpty: return "error_create_post_empty_image".localized.firstUppercased case .userIdEmpty: return "error_create_post_empty_user".localized.firstUppercased } } } }
Добавим расширениям соответствие протоколу Codable и опишем соответствие свойств объекта Post ключам в json, который будем получать с бэкенда.
extension Post: Codable { private enum CodingKeys: String, CodingKey { case title case createdAt = "created_at" case userId = "user_id" case imageName = "image_name" } }
Возвращаемся в наш HomeView, добавляем ему свойство для обновления данных с помощью RxSwift:
private let data = PublishSubject<[Post]>()
Теперь пропишем биндинг таблицы и нашего поля data:
private func bindTable() { data.bind(to: tableView.rx.items(cellIdentifier: PostCell.identifier, cellType: PostCell.self)) { _, post, cell in cell.value = post }.disposed(by: disposeBag) }
Здесь мы указываем, какой класс ячейки использовать. В нашем случае это PostCell. Давайте посмотрим на его код:
final class PostCell: BaseTableViewCell { override var value: Any? { didSet { guard let post = value as? Post else { return } fillUI(post: post) } } // MARK: - subviews private let gradient = CAGradientLayer() private var indicator: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .whiteLarge) view.color = .SLBlackTwo view.startAnimating() return view }() private var imgView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.backgroundColor = UIColor.lightGray.withAlphaComponent(0.7) view.clipsToBounds = true view.layer.cornerRadius = 4 return view }() private let titleLabel: UILabel = { let label = UILabel() label.textColor = .white label.font = UIFont.systemFont(ofSize: 17, weight: .bold) label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping return label }() private let dateLabel: UILabel = { let label = UILabel() label.textColor = UIColor.white.withAlphaComponent(0.8) label.font = UIFont.systemFont(ofSize: 13) label.textAlignment = .right return label }() override func configure() { super.configure() addSubview(imgView) addSubview(titleLabel) addSubview(dateLabel) imgView.easy.layout(Left(8), Right(8), Top(16), Height().like(imgView, .width), Bottom()) titleLabel.easy.layout(Left(16), Bottom(16).to(imgView, .bottom), Height(>=20).with(.required)) dateLabel.easy.layout(Left(10).to(titleLabel, .right), Top(10).to(bottom, .imgView), Height(20), Right(16), Bottom(16).to(imgView, .bottom)) imgView.addSubview(indicator) indicator.easy.layout(Center()) } // MARK: - private private func fillUI(post: Post) { titleLabel.text = post.title if Calendar.current.isDateInToday(post.createdAt) { dateLabel.text = post.createdAt.hh.mm } else { dateLabel.text = post.createdAt.dd.MM.yy } guard let reference = post.reference else { return } imgView.sd_setImage(with: reference, placeholderImage: nil) { [weak self] _, _, _, _ in self?.indicator.stopAnimating() guard let gradient = self?.gradient else { return } gradient.removeFromSuperlayer() gradient.frame = self?.imgView.frame ?? .zero gradient.position.x -= 8 gradient.colors = [ UIColor(white: 0, alpha: 0).cgColor, UIColor(white: 0, alpha: 0).cgColor, UIColor(white: 0, alpha: 0.6).cgColor ] self?.imgView.layer.insertSublayer(gradient, at: 0) } } }
Класс ячейки PostCell содержит, помимо прочего, свойство value, при изменении значения которого и происходит заполнение ячейки данными с помощью метода fillUI(post: Post).
Таким образом, как только мы присваиваем свойству value ячейки, она заполняется, и наша таблица отрисовывает данные.
Добавление публикации
Теперь давайте создадим еще один модуль — экран добавления публикации. Идем в терминал и набираем:
generamba gen CreatePost swifty_mvp
Заготовка есть.
На этом экране добавляем UITextField для ввода названия публикации и UIImageView для отображения превью выбранного изображения.
import UIKit import EasyPeasy import ImageSource protocol CreatePostViewDelegate: class { func titleUpdated(text: String) } final class CreatePostView: BaseView { private weak var delegate: CreatePostViewDelegate? // subviews private let imageView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) view.clipsToBounds = true return view }() private let titleField: UITextField = { let field = UITextField() field.borderStyle = .none field.textColor = .white field.font = UIFont.systemFont(ofSize: 17, weight: .semibold) field.attributedPlaceholder = "title_placeholder".localized.firstUppercased.attributed(attributes: [.foregroundColor: UIColor.lightGray]) field.keyboardAppearance = .dark return field }() // MARK: - initializers init(delegate: CreatePostViewDelegate) { super.init(frame: .zero) self.delegate = delegate } required init?(coder: NSCoder) { super.init(coder: coder) } // MARK: - methods func prepareView() { addSubview(titleField) addSubview(imageView) titleField.easy.layout(Top().to(safeAreaLayoutGuide, .top), Left(16), Right(16), Height(50)) imageView.easy.layout(Top().to(titleField, .bottom), Left(), Right(), Height().like(imageView, .width)) let taper = UITapGestureRecognizer(target: self, action: #selector(tapAction)) addGestureRecognizer(taper) titleField.delegate = self titleField.becomeFirstResponder() } func set(image: ImageSource) { DispatchQueue.main.async { self.imageView.setImage(fromSource: image) } } func freeze() { titleField.isEnabled = false endEditing(true) } // MARK: - actions @objc private func tapAction() { endEditing(true) } } extension CreatePostView: UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let newText = NSString(string: textField.text!).replacingCharacters(in: range, with: string) delegate?.titleUpdated(text: String(newText)) return true } }
Давайте пока отложим работу в модуле CreatePost и вернемся в модуль главного экрана.
Добавим главному экрану кнопку создания публикации.
Идем в HomeViewController и добавляем метод setupNavigationBar:
private func setupNavigationBar() { navigationItem.title = "home".localized.firstUppercased navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addAction)) }
Этот метод мы будем вызывать во viewDidLoad, чтобы «настроить» наш UINavigationBar сразу после того, как вью загрузилась.
В качестве селектора указан метод addAction. Этот метод просто «говорит» презентеру, что нужно показать медиапикер.
Дальше презентер обращается к навигатору и сообщает ему, что нужно перейти к пикеру.
Посмотрим на код навигатора:
import UIKit import Swinject import ImageSource import Paparazzo class HomeNavigator: Navigator { private var resolver: Resolver! enum Destination { case createPost(imgItem: MediaPickerItem) case showPicker(completion: (_ items: [MediaPickerItem]) -> Void) } internal weak var sourceViewController: UIViewController? // MARK: - Initializer init(sourceViewController: UIViewController?, resolver: Resolver) { self.sourceViewController = sourceViewController self.resolver = resolver } // MARK: - Navigator func navigate(to destination: HomeNavigator.Destination) { if let destinationViewController = makeViewController(for: destination) { switch destination { case .showPicker: sourceViewController?.present(destinationViewController, animated: true, completion: nil) default: if let navVC = sourceViewController?.navigationController { navVC.pushViewController(destinationViewController, animated: true) } } } } // MARK: - Private internal func makeViewController(for destination: Destination) -> UIViewController? { switch destination { case .createPost(let item): if let vc = resolver.resolve(CreatePostViewType.self) as? CreatePostViewController { vc.presenter?.set(imageSource: item.image) return vc } case .showPicker(let completion): let viewController = PaparazzoFacade.paparazzoViewController( theme: PaparazzoUITheme(), parameters: MediaPickerData( items: [], maxItemsCount: 1 ), onFinish: { items in completion(items) } ) return viewController } return nil } }
Чтобы следовать протоколу Navigator, реализуем методы makeViewController(for:) и navigate(to:)
В createViewController(for:) проверяем destination и в случае, когда нам нужно показать пикер, инициализируем PaparazzoViewController с настройками:
- изначально нет выбранных медиа элементов;
- максимально мы можем выбрать 1 элемент (т. е. отключаем множественный выбор).
Еще у нас есть completion замыкание, чтобы показать пикер. Указываем выполнение этого замыкания в блоке onFinish при инициализации PaparazzoViewController.
Дальше просто показываем в модальном режиме наш сконфигурированный PaparazzoViewController, получаем медиапикер с возможностью выбрать изображение из фотогалереи или сделать снимок.
Теперь возвращаемся в презентер главного модуля — в то место, где мы получаем сигнал от вью, что нам нужно показать пикер, и напишем следующий код:
navigator.navigate(to: .showPicker(completion: { [weak self] items in guard let item = items.first else { return } self?.navigator.navigate(to: .createPost(imgItem: item)) }))
Здесь мы указываем, что сразу после того, как отработал медиапикер, и у нас в completion блоке есть items, мы выбираем первый айтем. А как мы помним, у нас медиапикер сконфигурирован на выбор только одного изображения. Далее переходим с этим айтемом в экран createPost.
Там подставляем выбранное изображение в форму добавления публикации.
Нам остается только ввести название публикации и нажать «Сохранить».
Провайдер
Мы создали структуру Post и заполнили её данными: название и изображение.
Теперь нам нужно сохранить нашу публикацию на бэкенде, в нашем случае в Firebase.
Для этого опишем провайдера для работы с публикациями:
import Foundation import FirebaseFirestore import FirebaseFirestoreSwift import FirebaseStorage protocol PostProvider: BaseProvider { func getCollection(completion: @escaping (_ collection: [Post], _ error: Error?) -> Void) func create(post: Post, completion: @escaping (_ error: Error?) -> Void) } final class PostProviderImpl: PostProvider { internal var collectionName = "posts" private let db = Firestore.firestore() func getCollection(completion: @escaping (_ collection: [Post], _ error: Error?) -> Void) { db.collection(collectionName) .order(by: Post.Fields.createdAt.rawValue, descending: true) .getDocuments { snapshot, error in guard nil == error else { completion([], error!); return } guard let snapshot = snapshot else { completion([], nil); return } let collection = snapshot.documents.compactMap { document -> Post? in guard var post = try? document.data(as: Post.self) else { return nil } guard let imageName = post.imageName else { return post } post.reference = Storage.storage().reference().child("images/\(imageName)") return post } completion(collection, nil) } } func create(post: Post, completion: @escaping (Error?) -> Void) { do { _ = try db.collection(collectionName).addDocument(from: post) completion(nil) return } catch { completion(error) } } }
Здесь мы описали провайдер протоколом и реализовали методы протокола в классе имплементации.
У нас получилось только два метода: один для получения списка публикаций, второй для сохранения публикации.
Теперь нужно зарегистрировать наш провайдер в DI контейнере.
Создаем ProvidersAssembly:
import Swinject class ProvidersAssembly: Assembly { func assemble(container: Container) { container.register(PostProvider.self) { _ in PostProviderImpl() } } }
Затем добавляем ProvidersAssembly в список assemblies в RootApplicationService:
private let assemblies: [Assembly] = [ ServicesAssembly(), ProvidersAssembly(), GuestAssembly(), HomeAssembly(), CreatePostAssembly() ]
PostService
Теперь нам нужно соединить только что созданный и заполненный объект публикации и провайдера для сохранения публикации на бекенде.
Для этого нам нужен PostService.
import Foundation import RxSwift protocol PostService { var trigger: PublishSubject<TriggerEvent> { get } func getPosts(completion: @escaping (_ posts: [Post], _ error: Error?) -> Void) func create(post: Post, completion: @escaping (_ error: Error?) -> Void) } final class PostServiceImpl: PostService { private var provider: PostProvider! private var storageService: StorageService! private(set) var trigger: PublishSubject<TriggerEvent> = PublishSubject<TriggerEvent>() init(provider: PostProvider, storageService: StorageService) { self.provider = provider self.storageService = storageService } func getPosts(completion: @escaping ([Post], Error?) -> Void) { provider.getCollection(completion: { (collection, error) in completion(collection, error) }) } func create(post: Post, completion: @escaping (Error?) -> Void) { if let error = validate(post: post).first { return completion(error) } guard let imgData = post.image else { return completion(Post.ValidationError.imageEmpty) } imgData.fullResolutionImageData { data in guard let imgData = data else { return } self.storageService.create(data: imgData) { name, error in guard nil == error else { return completion(error) } var post = post post.imageName = name self.provider.create(post: post) { error in self.trigger.onNext(.saved) completion(error) } } } } // MARK: - private private func validate(post: Post) -> [Post.ValidationError] { var errors = [Post.ValidationError]() if post.title.isEmpty { errors.append(Post.ValidationError.titleEmpty) } if nil == post.image { errors.append(Post.ValidationError.imageEmpty) } if nil == post.userId { errors.append(Post.ValidationError.userIdEmpty) } return errors } }
Как раз здесь мы и обращаемся к провайдеру, когда нам нужно получить публикации, чтобы отобразить их на главном экране, или когда нам нужно сохранить публикацию.
Не забываем зарегистрировать PostService в ServicesAssembly:
import Swinject class ServicesAssembly: Assembly { func assemble(container: Container) { container.register(UserService.self) { _ in UserServiceImpl() } container.register(StorageService.self) { resolver in StorageServiceImpl(storageProvider: resolver.resolve(StorageProvider.self)!) } container.register(PostService.self) { resolver in PostServiceImpl(provider: resolver.resolve(PostProvider.self)!, storageService: resolver.resolve(StorageService.self)!) }.inObjectScope(.container) } }
Здесь у нас ещё есть StorageService — это сервис для работы с файлами. Его код можно посмотреть в проекте.
Итого
Мы сделали приложение, в котором можно авторизоваться, увидеть ленту пользователя, снять фото или загрузить из библиотеки. Его можно развивать как угодно: от допиливания функциональности соцсетей до ухода в группировку фотографий для определенных категорий пользователей, создания фотоальбомов, страниц пользователей и так далее.