Предисловие
Начну с того, что в поисках информации по SwiftUI, я не нашел ничего лучше, чем туториал от корпорации Apple - вот он. Если плохо с английским, браузер Chrome в помощь, справляется на ура. Достаточно повторить весь предоставленный материал и 99% вопросов отпадут сами собой. Материал рассчитан на людей, которые имеют какой-либо незначительный опыт разработки и хотят познакомиться со SwiftUI.
Немного моего скромного мнения о SwiftUI
Фреймворк уже достаточно мощный и к Storyboard я более не вернусь. Конечно часто приходится использовать UIKit, но думаю с течением времени эта необходимость сойдет на нет. Производительность при правильном проектировании, просто поражает воображение, скорость разработки так же удивляет. Доказывать никому и ничего не буду, как говорится, сколько людей, столько и мнений, пожалуй начну.
О чем публикация
Шпаргалка по SwiftUI
Некоторые нюансы работы SwiftUI
PageView на SwiftUI
WebImage на SwiftUI (AsyncImage)
В общем, постараюсь поделится своими значимыми наблюдениями, которые я выполнил, в момент разработки клиент-серверного приложения. Суть приложения - обои на рабочий стол iPhone. Кто хочет пощупать, оно есть в AppStore - сильвупле. Приложение умеет сохранять картинки (Data) в .cache, сохранять картинки в фотопленку, показывать картинки, работать с сетью (запросы списков, авторизация и т.д.), грубо говоря ничего сложного. Об этом и как это реализовано, в том числе и будет идти речь.
Шпаргалка по SwiftUI
@State - своеобразная обертка для свойств, их можно передавать и они реактивно связаны с представлением.
struct MyView: View {
@State var text: String = ""
var body: some View {
TextField("Placeholder", text: $text)
Text(text)
Если попробовать объяснить простым языком, в представленном выше листинге, можно наблюдать свойство структуры представления - text, по сути переменная, которая одновременно является и String т.е. можно использовать её как обычно, плюс находится в обертке (Binding<String>). Некоторые представления требуют именно Binding<>, как например TextField() (почему так происходит, поймете чуть ниже), указывать такие свойства необходимо через $ (это не отсылка к php). Соответственно, указывая это свойство в любом представлении (в SwiftUI, каждый элемент является представлением), например в Text(), мы получаем реактивную связку и изменяя TextField(), мы сразу увидим изменения в Text(). Просто великолепная возможность, кроме этого мы можем менять значения таких свойств из методов без дополнительных действий, к примеру
struct MyView: View {
var myProperty = 0
mutating func myMethod(){
self.myProperty += 1
}
var body: some View {
...
как вам известно, в структурах, для изменения обычных свойств методами, необходимо использовать mutating, а вот свойств в обертке Binding<> как вы уже возможно догадались, это не касается, они поддаются прямому изменению.
struct MyView: View {
@State var myProperty = 0
func myMethod(){
self.myProperty += 1
}
var body: some View {
...
Свойства Binding<> не могут быть вычисляемыми и им нельзя назначать сеттеры, но есть выход - для всех действий надлежит использовать модификатор onChange
//-------- БЕЗ Binding
struct MyView: View {
var myProperty: Int = 0 {
didSet {
print("свойство myProperty изменилось")
}
}
mutating func myMethod(){
self.myProperty += 1
}
var body: some View {
VStack {
Text(String(myProperty))
}
...
//-------- С Binding
struct MyView: View {
@State var myProperty: Int = 0
func myMethod(){
self.myProperty += 1
}
var body: some View {
VStack {
Text(String(myProperty))
}
.onChange(of: myProperty) { _ in
print("свойство myProperty изменилось")
}
...
@Binding - получаемое структурой извне, свойство, с оберткой @State
struct MyView: View {
@State var myProperty: String = "Текст"
var body: some View {
Text(myProperty)
MySecondView(myProperty: $myProperty)
}
}
struct MySecondView: View {
@Binding var myProperty: String
var body: some View {
TextField("Placeholder", text: $myProperty)
}
}
Выше можно наблюдать, как текст в MyView связан с полем ввода в MySecondView. Теперь я думаю стало понятно, почему каждый элемент вашего представления является представлением, а так же иногда требует именно свойства в Binding<> обертке. Это очень изящное и простое решение, которое позволят связывать различные структуры между собой, разбивать сложные представления на части.
@StateObject - обертка для объекта, которая по сути является аналогичной @State, часто бывает удобно для структуризации, с той лишь разницей, что нельзя передать объект @Binding целиком (для этого существует другое решение), а только его свойства
struct MyView: View {
@StateObject var myObject: MyClass = MyClass()
var body: some View {
Text(myObject.text)
Text(myObject.title)
MySecondView(myProperty: $myObject.text)
}
}
struct MySecondView: View {
@Binding var myProperty: String
var body: some View {
TextField("Placeholder", text: $myProperty)
}
}
class MyClass: ObservableObject {
@Published var title: String = ""
@Published var text: String = ""
}
@ObservedObject - как раз то, что помогает принять @StateObject из другого представления (никогда не следует использовать его для инициализации экземпляра).
struct MyView: View {
@StateObject var myObject: MyClass = MyClass()
var body: some View {
Text(myObject.text)
Text(myObject.title)
MySecondView(myObject: myObject)
}
}
struct MySecondView: View {
@ObservedObject var myObject : MyClass
var body: some View {
TextField("Placeholder", text: $myObject.text)
}
}
@EnvironmentObject - я бы назвал это точкой входа в приложение, если вы знакомы с различными фрейворками, работой с Docker и т.д., вам хорошо знаком файл .env (переменные окружения), так вот @EnvironmentObject по своей сути и есть объект окружения, но со своим методами и содержанием, который может так же содержать объекты. Объявляется в начале приложения и передается посредством модификатора главному представлению.
@main
struct myApp: App {
@StateObject var myObject: MyClass = MyClass()
var body: some Scene {
WindowGroup {
MyView()
.environmentObject(myObject)
}
}
}
Это позволяет использовать его во всех представлениях, просто указав его наличие.
struct MyView: View {
@EnvironmentObject var myObject: MyClass
var body: some View {
Text(myObject.text)
Text(myObject.title)
MySecondView()
}
}
struct MySecondView: View {
@EnvironmentObject var myObject: MyClass
var body: some View {
TextField("Placeholder", text: $myObject.text)
}
}
Что с одной стороны удобно, но может вызвать ряд неудобств т.к. @EnvironmentObject (среда) передается после инициализации структуры, следовательно использовать init() у Вас не получится (будет ошибка).
.onAppear модификатор - иногда необходимо сделать какие-либо действия в момент инициализации представления и очевидным решением будет внедрение функционала в init()
struct MyView: View {
@StateObject var myObject: MyClass = MyClass()
var body: some View {
Text(myObject.text)
Text(myObject.title)
MySecondView(myObject)
}
}
struct MySecondView: View {
@ObservedObject var myObject : MyClass
func hello() {
self.myObject.text = "hello"
}
init(_ myObject: MyClass){
self.myObject = myObject
self.hello()
}
var body: some View {
TextField("Placeholder", text: $myObject.text)
}
}
но как мы выяснили ранее, использование @EnvironmentObject блокирует эту возможность, onAppear как раз таки решает эту проблему
struct MyView: View {
@StateObject var myObject: MyClass = MyClass()
var body: some View {
Text(myObject.text)
Text(myObject.title)
MySecondView(myObject: myObject)
}
}
struct MySecondView: View {
@ObservedObject var myObject : MyClass
func hello() {
self.myObject.text = "hello"
}
var body: some View {
TextField("Placeholder", text: $myObject.text)
.onAppear{
self.hello()
}
}
}
Код заключенный в модификатор будет выполнен в момент появления представления на экране, что бывает более удобно нежели работа с инициализатором, который выполняется в момент инициализации и в целом может здорово повысить производительность. Конечно следует учитывать отсутствие возможности задавать свои имена при передачи аргументов и необходимость гарантировать появление.
.onDisappear модификатор - противоположность onAppear, срабатывает когда представление покидает экран.
Модификаторы - средство стилизации/изменения представлений, можно писать свои посредством расширения View, рассказывать особо нечего, если вам тяжело понять как они работают, посмотрите на следующий листинг
struct MySecondView: View {
@ObservedObject var myObject : MyClass
func hello() {
self.myObject.text = "hello"
}
var body: some View {
TextField("Placeholder", text: $myObject.text)
.onAppear{
self.myObject
.clearText()
.clearTitle(){
print("Заголовок очищен")
}
}
}
}
class MyClass: ObservableObject {
@Published var title: String = "cell"
@Published var text: String = "cell"
public func clearText() -> MyClass {
self.text = "d"
return self
}
public func clearTitle(_ closure: @escaping () -> Void) -> MyClass {
self.title = "d"
return self
}
}
собственно, наглядный пример, который позволяет понять принцип их работы.
Нюансы SwiftUI
TableView
Первый кровопийца в моем списке пожирателей нервной системы. Самый простой и популярный способ представить полноэкранный просмотр чего либо, достаточно применить один модификатор.
TabView {
...
Content()
Content()
...
}
.tabViewStyle(.page(indexDisplayMode: .never))
Он манит своей легкостью в использовании, забейте в Google "PageView SwiftUI" и найдете десятки вариаций использования TabView, но по факту подобная реализация от лукавого. Если мы добавим всего пару сотен пустых представлений с цветом, все начинает лагать даже на iPhone 13 Pro
struct MyView: View {
var body: some View {
TabView {
ForEach((1...100).reversed(), id: \.self) { _ in
Color.gray
Color.green
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}
}
Когда речь идет о тысячах, речи о производительности и быть не может. С помощью в туториале от Apple, который упоминался выше, я нашел для себя вариант решения проблемы, об этом ниже, когда буду разбирать PageView.
DispatchQueue
Все изменения в ObservableObject объектах, необходимо производить в главном потоке т.к. SwiftUI подобен UIKit, где вносить изменения в представления разрешено только в главном потоке. У Вас конечно получится и вне, жесткого ограничения нет, но к каким результатам это приведет неизвестно. Делается очень просто.
...
func myMethod(){
let cell: Int = 10
let result: Int = cell * 20
DispatchQueue.main.async {
self.property = result
}
}
...
Combine
Сейчас в нем не предусмотрена реализация URLSession.shared.downloadTask, поэтому не нужно искать легких путей и использовать для загрузки файлов предусмотренный dataTask, согласно документации Apple его надлежит применять исключительно для загрузки небольшого количества данных. Файлы к этому конечно же не относятся. Я понимаю, что он манит своей простотой, но тем не менее, это крайне негативно сказывается на производительности. Пример реализации будет ниже в WebImage.
PageView
Apple позволят использовать компоненты других фреймворков в SwiftUI и решение вполне себе очевидно. Использовать UIViewControllerRepresentable
struct PageView: UIViewControllerRepresentable {
@ObservedObject var store: Store
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[context.coordinator.controllers[store.currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageView
var controllers = [UIViewController]()
init(_ pageViewController: PageView) {
parent = pageViewController
controllers = parent.store.arrayPages.map { UIHostingController(rootView: $0) }
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return controllers.last
}
return controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == controllers.count {
return controllers.first
}
return controllers[index + 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = controllers.firstIndex(of: visibleViewController) {
parent.store.currentPage = index
}
}
}
}
Я думаю простейшая реализация не требует каких либо комментариев, как вы можете заметить, мы без проблем можем использовать @StateObject, @EnvironmentObject и прочие плюшки SwiftUI, пример использования
struct MyView: View {
@StateObject var store: Store = Store()
var body: some View {
PageView(store: store)
}
}
class Store: ObservableObject {
@Published var currentPage: Int = 0
var arrayPages : [Color] {
var result : [Color] = []
for _ in 0...1000 {
result.append(Color.green)
result.append(Color.gray)
}
return result
}
}
Пример примитивный, но думаю раскрывает возможности, можете скопировать код в пустой проект и побаловаться, нет намека на лаги даже на винтажном iPhone 6s. Никто не мешает передавать вам готовый массив или получать его где либо еще, передавать отдельный счетчик страниц и т.д. В приложении я использовал @StateObject т.к. в моей архитектуре это удобно. В листинге просто пример, не стоит использовать его как руководство.
WebImage
SwiftUI не имеет встроенных (не имел до выхода iOS 15) средств для загрузки изображений из интернета и по сей день не имеет средств для их загрузки с последующим кэшированием/сохранением. Ниже я приведу пример кода, который покажет на простом примере, как качать изображения из сети, а затем их использовать. ПОЖАЛУЙСТА не используйте это в своих проектах, пример очень сильно упрощен для наглядности и выполнен не правильно, выполнен в рамках структуры (классы - ссылочный тип, структуры копируемый) и негативно влияет на ОЗУ, не выносит операции в поток утилит и т.д. создан исключительно для ознакомления и вашей возможности побаловаться. Поэтому если решение Вам понравится, разберите каждую строчку кода и выполните реализацию самостоятельно и правильно.
Для начала создадим структуру
struct WebImage: View {
let url: String
var body: some View {
...
}
}
Нам нужно будет передавать в нее ссылку
struct MyView: View {
@StateObject var store: Store = Store()
var body: some View {
WebImage(url: "https://mysite.example/image.jpg")
}
}
Так как мы планируем хранить изображение в памяти нам нужен метод, который переделает ссылку в имя файла.
func urlToNameFile(url: String) -> String {
var result: String
result = url.replacingOccurrences(of: "http://", with: "domen-", options: .literal, range: nil)
result = result.replacingOccurrences(of: "https://", with: "domen-", options: .literal, range: nil)
result = result.replacingOccurrences(of: "/", with: "-", options: .literal, range: nil)
result = result.replacingOccurrences(of: ".ru", with: "-", options: .literal, range: nil)
return result
}
Создадим метод проверки наличия файла/директории
func checkPath(url: URL) -> Bool {
let manager = FileManager.default
return manager.fileExists(atPath: url.path)
}
Создадим промежуточное вычисляемое свойство, содержащее путь к папке с кэшем
@State private var cacheFolderCell: URL? = nil
Затем нам нужен метод возвращающий путь к папке с кэшем
func getPathCacheFolder() -> URL? {
let manager = FileManager.default
let defaultPathsSearch: URL
do {
defaultPathsSearch = try manager.url(
for: FileManager.SearchPathDirectory.cachesDirectory,
in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil,
create: true)
}
catch { return nil }
let cacheFolder = defaultPathsSearch.appendingPathComponent("image")
if !checkPath(url: cacheFolder) {
do {
try manager.createDirectory(at: cacheFolder, withIntermediateDirectories: false, attributes: nil)
}
catch {return nil }
}
self.cacheFolderCell = cacheFolder
return cacheFolder
}
В методе мы находим папку .cache, затем создаем папку image и возвращаем путь к ней в виде URL
Далее, так как мы все таки делаем пример, создадим страшную вещь.
var cacheFolder: URL {
if let directory = self.cacheFolderCell {
return directory
} else {
return self.getPathCacheFolder()!
}
}
В общем если что-либо пойдет не так, приложение крашнется) Не делайте так. Мы получаем удобный доступ к опциональному значению через явное извлечении. Можно конечно просто «!» ставить и избежать этого обходного пути, но разве это интересно? Продолжим.
Заводим флаг загрузки.
@State var isLoadedImage: Bool = false
Реализуем метод извлечения изображения из Data
func getImage(fileName: String) -> Image? {
let imagePath = cacheFolder.appendingPathComponent(fileName)
guard checkPath(url: imagePath) else {return nil}
guard let nsData = NSData(contentsOfFile: imagePath.path) else { return nil }
let data = Data(referencing: nsData)
guard let uiImage = UIImage(data: data) else { return nil }
return Image(uiImage: uiImage)
}
Создание свойства с изображением и для прогресса загрузки.
@State var image: Image? = nil
@State var progressCell: NSKeyValueObservation? = nil
@State var progress: Double = 0
И теперь когда у нас есть, все что необходимо - реализуем загрузку изображения.
func downloadImage(){
if isLoadedImage { return }
let nameImage = self.urlToNameFile(url: self.url)
if let image = getImage(fileName: nameImage) {
self.image = image
self.isLoadedImage = true
} else {
guard let valideUrl = URL(string: url) else { return }
let session = URLSession.shared
let task = session.downloadTask(with: valideUrl){ location, response, error in
progressCell?.invalidate()
if let _ = error {
//делаем что-нибудь
return
}
guard let localUrl = location else { return }
let fileUrl = cacheFolder.appendingPathComponent(nameImage)
if !checkPath(url: fileUrl) {
do { try FileManager.default.moveItem(atPath: localUrl.path, toPath: fileUrl.path) }
catch { return }
}
guard let image = getImage(fileName: nameImage) else { return }
self.image = image
self.isLoadedImage = true
}
self.progressCell = task.progress.observe(\.fractionCompleted) { progress, _ in
self.progress = progress.fractionCompleted
}
}
}
Реализуем в body отображение
var body: some View {
VStack{
if image != nil {
image!
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "photo")
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.onAppear {
self.downloadImage()
}
}
В совокупности должен получится вот такой код (c вашей ссылкой на изображение)
struct MyView: View {
@StateObject var store: Store = Store()
var body: some View {
WebImage(url: "https://mysite.example/image.jpg")
}
}
struct WebImage: View {
let url: String
@State private var cacheFolderCell: URL? = nil
var cacheFolder: URL {
if let directory = self.cacheFolderCell {
return directory
} else {
return self.getPathCacheFolder()!
}
}
@State var isLoadedImage: Bool = false
@State var image: Image? = nil
@State var progressCell: NSKeyValueObservation? = nil
@State var progress: Double = 0
var body: some View {
VStack{
if image != nil {
image!
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "photo")
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.onAppear {
self.downloadImage()
}
}
func getImage(fileName: String) -> Image? {
let imagePath = cacheFolder.appendingPathComponent(fileName)
guard checkPath(url: imagePath) else {return nil}
guard let nsData = NSData(contentsOfFile: imagePath.path) else { return nil }
let data = Data(referencing: nsData)
guard let uiImage = UIImage(data: data) else { return nil }
return Image(uiImage: uiImage)
}
func downloadImage(){
if isLoadedImage { return }
let nameImage = self.urlToNameFile(url: self.url)
if let image = getImage(fileName: nameImage) {
self.image = image
self.isLoadedImage = true
} else {
guard let valideUrl = URL(string: url) else { return }
let session = URLSession.shared
let task = session.downloadTask(with: valideUrl){ location, response, error in
progressCell?.invalidate()
if let _ = error {
//делаем что-нибудь
return
}
guard let localUrl = location else { return }
let fileUrl = cacheFolder.appendingPathComponent(nameImage)
if !checkPath(url: fileUrl) {
do { try FileManager.default.moveItem(atPath: localUrl.path, toPath: fileUrl.path) }
catch { return }
}
guard let image = getImage(fileName: nameImage) else { return }
self.image = image
self.isLoadedImage = true
}
self.progressCell = task.progress.observe(\.fractionCompleted) { progress, _ in
self.progress = progress.fractionCompleted
}
task.resume()
session.finishTasksAndInvalidate()
}
}
func urlToNameFile(url: String) -> String {
var result: String
result = url.replacingOccurrences(of: "http://", with: "domen-", options: .literal, range: nil)
result = result.replacingOccurrences(of: "https://", with: "domen-", options: .literal, range: nil)
result = result.replacingOccurrences(of: "/", with: "-", options: .literal, range: nil)
result = result.replacingOccurrences(of: ".ru", with: "-", options: .literal, range: nil)
return result
}
func checkPath(url: URL) -> Bool {
let manager = FileManager.default
return manager.fileExists(atPath: url.path)
}
func getPathCacheFolder() -> URL? {
let manager = FileManager.default
let defaultPathsSearch: URL
do {
defaultPathsSearch = try manager.url(
for: FileManager.SearchPathDirectory.cachesDirectory,
in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil,
create: true)
}
catch { return nil }
let cacheFolder = defaultPathsSearch.appendingPathComponent("image")
if !checkPath(url: cacheFolder) {
do {
try manager.createDirectory(at: cacheFolder, withIntermediateDirectories: false, attributes: nil)
}
catch {return nil }
}
self.cacheFolderCell = cacheFolder
return cacheFolder
}
}
Но повторюсь, подобные вещи, таким способом не реализуются. Сюда нужно сделать загрузку в NSCache и извлечение из него (об этом информации достаточно), разбить все это на сервисы и т.д. реализовать множество различных проверок. Моя же задача была предоставить пример.
Собственно по теме текущего материала я предоставил все, что хотел. По мере возможности постараюсь выложить изолированный сервис контейнер для загрузки изображений, вне контекста, но это уже другая история.
Если есть что добавить, или исправить — добро пожаловать в комментарии.