Как стать автором
Обновить

Первое приложение на SwiftUI

Время на прочтение14 мин
Количество просмотров6.4K

Предисловие

Начну с того, что в поисках информации по 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 и извлечение из него (об этом информации достаточно), разбить все это на сервисы и т.д. реализовать множество различных проверок. Моя же задача была предоставить пример.

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

Если есть что добавить, или исправить — добро пожаловать в комментарии.

Теги:
Хабы:
+2
Комментарии8

Публикации

Истории

Работа

Swift разработчик
30 вакансий
iOS разработчик
22 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн