Імплементуємо SwiftUI до UIKit і навпаки

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Катерина Ніколаєва, iOS Software Engineer в компанії Uklon. Про запуск SwiftUI було анонсовано компанією Apple на Worldwide Developers Conference (WWDC) 3 червня 2019 року та додано в iOS 13 і macOS Catalina. Новий декларативний підхід до верстки інтерфейсів не залишив байдужими багатьох iOS-розробників, оскільки це дало можливість створювати проєкти повністю на Swift, уникаючи Objective-C коду навіть під капотом.

Також це спрощений синтаксис та можливість використовувати Live Preview — перегляд змін у реальному часі без потреби перезапуску застосунку, що значно полегшує процес розробки, оскільки ви можете бачити результат миттєво. На простих прикладах з відеотуторіалів все виглядає просто казково. Але чи так все чарівно у реально великих довгострокових проєктах?

Наша команда довгий час залишалась осторонь цього підходу. Ми терпляче чекали, даючи йому «настоятись», бо, відверто кажучи, перші ітерації були доволі «сирі» і не тільки не спрощували поточні нюанси UIKit-імплементацій, а ще й породжували нові складнощі та проблеми. Проте з часом і оновленням версій iOS SwiftUI також розвивається і покращується, тому ми активно взялись за поступову інтеграцію цього коду у наш проєкт.

Цей матеріал буде корисним для всіх iOS-розробників, які мають намір рухатись в сторону використання SwiftUI-реалізацій у поточних UIKit-проєктах.

Переваги SwiftUI над UIKit

Нижче наведено деякі ключові особливості, за якими SwiftUI постає у виграшній позиції в порівнянні з UIKit.

Декларативний синтаксис. UIKit є старішим імперативним фреймворком для розробки iOS-застосунків. SwiftUI використовує декларативний підхід до опису інтерфейсу, основною ідеєю якого є описання кінцевого результату, а не шляху його досягнення. Це спрощує розробку, роблячи код більш зрозумілим та гнучким і менш схильним до помилок.

Менша кількість коду. Для реалізації UI-компонентів на SwiftUI виконується набагато менше дій, ніж для аналогів на UIKit.

Розглянемо на прикладі виводу тексту з UILabel (UIKit) та Text (SwiftUI).

UIKit

let label = UILabel()
label.text = "Hello, World!"
label.textColor = UIColor.blue
label.font = UIFont.systemFont(ofSize: 14.0)
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)

Також треба додати усі відповідні констрейнти для правильного розташування компонента.

SwiftUI

Text("Hello, World!")
            .foregroundColor(.blue)
            .font(.system(size: 14))

SwiftUI також пропонує вищий рівень абстракції й простоти, особливо для створення складних інтерфейсів.

Кросплатформність. SwiftUI підтримує кросплатформну розробку, яка дає змогу використовувати той самий код для створення інтерфейсу для iOS, macOS, watchOS та tvOS. Це забезпечує більшу масштабованість та ефективність у розробці проєктів, які цінують однорідність на різних платформах Apple.

Інтеграція з Combine. SwiftUI досить легко та органічно інтегрується з фреймворком Combine для обробки подій і потоків даних. Це спрощує управління станом застосунку та пришвидшує реакцію на зміни.

Попри ці переваги, використання SwiftUI може мати деякі обмеження, особливо для проєктів, які потребують використання старіших версій iOS або специфічного для UIKit набору функцій. У таких випадках комбінування SwiftUI та UIKit може бути корисним підходом, про який і піде мова далі.

Перетворення View на UIViewController

Імплементувати SwiftUI в UIKit-проєкт є досить тривіальною задачею. Достатньо створити View, яка, по суті, буде відігравати роль вашого екрана, і обгорнути її в UIHostingController (клас у фреймворку SwiftUI) в якості його rootView. Розглянемо на простому прикладі. Маємо деяку View:

import SwiftUI

struct SwiftUIView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

Для перетворення SwiftUIView в UIViewController достатньо створити екземпляр SwiftUIView і всього одним рядком коду перетворити його на об’єкт UIViewController:

let view = SwiftUIView()
let viewController = UIHostingController(rootView: view)

З цього короткого прикладу ми отримуємо звичний UIViewController, який далі можна використовувати за потребами поставлених задач.

Навігація

Якщо з декількома SwiftUI-екранами використовується нативний для цього підходу спосіб навігації, то для суміші з UIKit краще використовувати звичайну UIKit-навігацію, а всі SwiftUIView перетворювати в UIViewController. Нашим архітектурним рішенням для таких задач є створення класу Coordinator, в якому відбувається логіка переходів між екранами.

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

final class Coordinator {
    
    private let presenter: UIViewController
    
    init(presenter: UIViewController) {
        self.presenter = presenter
    }
    
    func start() {
        let view = SwiftUIView()
        let vc = UIHostingController(rootView: view)
        vc.modalPresentationStyle = .overFullScreen
        presenter.present(vc, animated: true)
    }
    
    func dismiss() {
        presenter.presentedViewController?.dismiss(animated: true)
    }
}

В ініціалізатор класу Coordinator передаємо об’єкт UIViewController, який буде презентити SwiftUI-екран, де відбувається подальша логіка. Метод start() відповідає за його створення. Через presenter можна виконувати презенти інших екранів або закрити поточний, як показано в методі dismiss().

Використання View як UIView

Вище ми розглянули приклади, як із View зробити UIViewController. Але що робити, якщо є конкретний view controller або інша UIView, куди необхідно додати елементи SwiftUI без переписування елементу повністю? По суті, схема перетворення виглядає ідентично. Ми так само загортаємо View в UIHostingController і додаємо його view на потрібний компонент за допомогою методу addSubview(_ view: UIView).

someView.addSubview(hostingController.view)

Така реалізація виглядає досить просто, проте в процесі розробки був виявлений неочевидний нюанс. Під час додавання таким чином SwiftUIView до UIView на iOS 15 виникав зайвий відступ через SafeArea.

На прикладі одного з наших bottom sheets розкажу про цей випадок наочно. На першому скриншоті з версткою все добре, на другому чітко видно зайвий відступ, провокований висотою SafeArea

Проблема вирішувалась досить нетривіально. На просторах інтернету команда знайшла рішення, скореговане під розв’язання цієї задачі. В результаті був сформований метод disableSafeAreaInsets():

func disableSafeAreaInsets() {
    guard let viewClass = object_getClass(view) else { return }
    let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
    
    if let viewSubclass = NSClassFromString(viewSubclassName) {
        object_setClass(view, viewSubclass)
    } else {
        guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
        guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
        let selectorSafeAreaInsets = #selector(getter: UIView.safeAreaInsets)
        if let method = class_getInstanceMethod(UIView.self, selectorSafeAreaInsets) {
            
            let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                return .zero
            }
            
            class_addMethod(
                viewSubclass,
                selectorSafeAreaInsets,
                imp_implementationWithBlock(safeAreaInsets),
                method_getTypeEncoding(method)
            )
        }
        
        objc_registerClassPair(viewSubclass)
        object_setClass(view, viewSubclass)
    }
}

Завдяки цій імплементації ми розв’язали питання з зайвими відступами. На жаль, на цьому нюанси не закінчились.

Друга проблема, яку ми виявили, пов’язана з підняттям клавіатури. Розглянемо реальний приклад на основі відображення плашки з інформацією про умови виконання челенджу в рамках програми лояльності Uklon.

На скринах — конкретний кейс підняття SwiftUI View на рівень висоти клавіатури, хоча вона з’являлась на попередньому екрані при спробі редагування однієї з точок маршруту.

Рішенням було доповнення попереднього методу цим фрагментом коду:

let selectorKeyboardWillShow = NSSelectorFromString("keyboardWillShowWithNotification:")
  if let method2 = class_getInstanceMethod(viewClass, selectorKeyboardWillShow) {
                
  let keyboardWillShow: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in }
                
  class_addMethod(
      viewSubclass,
      selectorKeyboardWillShow,
      imp_implementationWithBlock(keyboardWillShow),
      method_getTypeEncoding(method2)
  )
}

Повний метод з обробкою подій ігнорування і підняття клавіатури викликався в екстеншині для UIHostingController:

extension UIHostingController {
    
    convenience init(rootView: Content, withoutSafeArea: Bool) {
        self.init(rootView: rootView)
        
        if withoutSafeArea {
            disableSafeAreaInsets()
        }
    }
    
    func disableSafeAreaInsets() {
        guard let viewClass = object_getClass(view) else { return }
        let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
        
        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        } else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
            guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
            let selectorSafeAreaInsets = #selector(getter: UIView.safeAreaInsets)
            if let method = class_getInstanceMethod(UIView.self, selectorSafeAreaInsets) {
                
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                
                class_addMethod(
                    viewSubclass,
                    selectorSafeAreaInsets,
                    imp_implementationWithBlock(safeAreaInsets),
                    method_getTypeEncoding(method)
                )
            }
            let selectorKeyboardWillShow = NSSelectorFromString("keyboardWillShowWithNotification:")
            if let method2 = class_getInstanceMethod(viewClass, selectorKeyboardWillShow) {
                
                let keyboardWillShow: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in }
                
                class_addMethod(
                    viewSubclass,
                    selectorKeyboardWillShow,
                    imp_implementationWithBlock(keyboardWillShow),
                    method_getTypeEncoding(method2)
                )
            }
            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}

Така костильна swizzling-реалізація не зачіпає всі SwiftUI-екрани, а додається тільки до конкретних View, у яких при ініціалізації в UIHostingController параметр withoutSafeArea передається як true.

Це рішення — тимчасова необхідність, бо за повного переходу на SwiftUI такі проблеми відпадуть самі собою, а поки радіємо можливості відносно безболісного впровадження сучасних рішень у стабільний, обкатаний роками наявний набір функцій 🙂

Компоненти UIKit у SwiftUI

Вище були розглянуті приклади імплементації SwiftUI View до UIKit. Далі розглянемо зворотний випадок, коли необхідно додати специфічний UIKit-елемент до SwiftUI-компонента. У нашій практиці така обгортка знадобилась для відображення відео, бо VideoPlayer, який надає нам SwiftUI, не відповідав поставленим вимогам задачі.

Нижче ми бачимо реалізацію UIView, яка є програвачем відео для конкретного продукту в форматі .mp4.

import AVKit
final class ProductVideoView: UIView {
    
    private var player: AVPlayer!
    private var videoPlayerLayer: AVPlayerLayer!
    private let videoContainerView = UIView()
    private var product: ProductModel?
    
    convenience init(product: ProductModel) {
        self.init(frame: .zero, product: product)
    }
    
    init(frame: CGRect, product: ProductModel) {
        super.init(frame: frame)
        self.product = product
        videoContainerView.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(videoContainerView)
        try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
        setupConstraints()
    }
    
    private func setupConstraints() {
        videoContainerView.snp.makeConstraints { make in
            make.centerX.equalTo(self)
            make.top.equalTo(self)
            make.height.equalTo(50)
            make.width.equalTo(90)
        }
    }
    
    func configure(selected: Bool) {
        if let product = product {
            if player != nil {
                if let videoPlayerLayer = videoPlayerLayer {
                    videoPlayerLayer.removeFromSuperlayer()
                }
            }
           if let path = Bundle.main.path(forResource: product.videoName, ofType: "mp4") {
                if selected {
                    let url = URL(fileURLWithPath: path)
                    player = AVPlayer(url: url)
                    videoPlayerLayer = AVPlayerLayer(player: player)
                    videoPlayerLayer.frame = CGRect(x: 0, y: 0, width: 90, height: 50)
                    videoContainerView.layer.addSublayer(videoPlayerLayer)
                    player.play()
                }
            }
        }
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
}

Метод configure(selected: Bool) відповідає за програвання відео після кліку на обраний продукт.

Далі, щоб включити цю view до SwiftUI-компонента створюємо структуру ProductView, яка підписується на протокол UIViewRepresentable — обгортку для UIKit-компоненту для його інтеграції в ієрархію SwiftUI. Він має обов’язкові для реалізації поля та методи, зокрема:

  • typealias UIViewType — псевдонім типу, який відповідає назві вашої UIView;
  • func makeUIView(context: Context) -> UIViewType — метод, в якому відбувається ініціалізація UIView;
  • func updateUIView(_ uiView: UIViewType, context: Context) — метод, який викликається при взаємодії з графічним елементом і оновленні його стану.

Повний фрагмент коду наведено нижче:

struct ProductView: UIViewRepresentable {
    
    var isSelected: Bool
    var product: ProductModel
    typealias UIViewType = ProductVideoView
    
    func makeUIView(context: Context) -> ProductsVideoView {
        return ProductsVideoView(product: product)
    }
    
    func updateUIView(_ uiView: ProductsVideoView, context: Context) {
        uiView.configure(selected: isSelected)
    }
}

Додатково до структури додано дві властивості isSelected та product, що необхідні для повноцінного створення об’єкта ProductsVideoView та обробці зміни його стану.

І вже готову ProductView легко додати до будь-якої SwiftUI View.

struct ProductSelectionView: View {
    
    @Binding var product: ProductModel
    
    var products: [ProductModel]
    
    var body: some View {
        LazyHStack(alignment: .center) {
            ForEach(products, id: \.type) { product in
                VStack(alignment: .center) {
                    UKCarTypeView(isSelected: self.product.type == product.type, product: product)
                        .frame(maxWidth: 90, maxHeight: 50)
                        .padding(.bottom, 50)
                    Text(product.type.title)
                        .font(Font.system(size: 16))
                        .foregroundColor(.black)
                        .padding([.leading, .trailing], 5)
                        .fixedSize()
                }
                .padding(8)
                .onTapGesture {
                    self.product = product
                }
            }
        }
    }
}

Результат використання ProductSelectionView проілюстровано нижче.

Висновок

Хоча SwiftUI все ще розвивається і може не підходити для всіх випадків використання, він надає потужний і сучасний інструмент для розробки інтерфейсів на платформах Apple та має всі доступні можливості для інтеграції у поточні UIKit-проєкти.

А з якими цікавими кейсами стикались ви на шляху до впровадження цього стильного, модного, молодіжного підходу до верстки у довготривалі проєкти? Діліться своїми враженнями у коментарях і дякую за увагу! 🙂

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Confirmation bias і тиск зі сторони джоб маркету — основні речі, які вимушують так «економити». Сам з радістю використовую SwiftUI де можливо, але прагматичний розрахунок частіше за все такий, що весь зекономлений час витрачається на костилі

Тобто вага додатку в 273 мб частково через використання .mp4 файлів та 10 відеоплеєрів на одному екрані? Розумію, що там LazyHStack, але все ж таки не «заважким» цей екран стає?
Також оптимізацією може бути винесення view для конжної кнопки в окрему структуру, таким чином простіше використати

Button { selectedProduct = product }
label: { ProductSelectionView(product: product, isSelected: selectedProduct.type == product.type }

Таким чином ви логічно виокремлюєте елементи та маєте нагоду більш гнучкого налаштування елементи разом із всіма рамками, додатковими кнопками (інформації, наприклад), якщо потрібно.
Стосовно свізлінгу, хіба .ignoresSafeArea() не працює для UIHostingController’у?
Хоча тепер зрозуміло, чому музика перестає видтворюватись, коли замовляєш таксі.
Дякую за увагу

Вітаю! Дякую за коментар і зацікавленість у матеріалі! Стосовно SafeArea помітили таку поведінку лише на iOS 15. UIHostingController додатково додавав свою і нашим свізелінгом, описаним у прикладі, ми її прибирали. Приклад оптимізації цікавий, для наших задач вистачило і поточної реалізації, але можна розглянути для інших кейсів. На рахунок ваги застосунку, дані відео досить легенькі проте переглянути усі ресурси, які можуть створювати додаткове навантаження, дійсно має сенс. А музика не має стопатись так як цей рядок коду

try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)

як раз дозволяє спільне відтворення відео і та інших фонових звуків 🙂

Цікаво чи це тільки ios15? Чи з мінімальною ios15 в таргеті?
Так, .mixWithOthers має не припиняти відтворювання поточного запису, але якщо ви використовуєте це як sharedInstance, то спочатку проходить стандартна реалізація, а потім вже налаштування сесії. -> переривання відтворювання поточної сесії. Умовні 10 разів lazyHStack можуть спричинити переривання при ініціалізації теж, бо це може бути не MainActor
Я, звісно, можу помилятись

Тільки для iOS 15. Проблем стосовно одночасного відтворення відео та інших аудіо елементів помічено не було в рамках поточної реалізації)

Lottie, так це ще одна залежність для застосунку, але не .mp4 та AVSession, який може зруйнувати UX

Підписатись на коментарі