eFusion 13 декабря 2019
Спонсорский материал

Пишем фотоприложение для iOS с нуля: большой туториал

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

Для работы нам понадобится среда разработки 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 — это сервис для работы с файлами. Его код можно посмотреть в проекте.

Итого

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

РУБРИКИ В СТАТЬЕ

МЕРОПРИЯТИЯ

Комментарии 1

ВАКАНСИИ

Редактор IT-издания
от 50000 RUB до 70000 RUB
Lead Backend Developer (Java)
по итогам собеседования
Программист С++
Санкт-Петербург, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG