Імплементуємо SwiftUI до UIKit і навпаки
Привіт! Мене звати Катерина Ніколаєва, 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-проєкти.
А з якими цікавими кейсами стикались ви на шляху до впровадження цього стильного, модного, молодіжного підходу до верстки у довготривалі проєкти? Діліться своїми враженнями у коментарях і дякую за увагу! 🙂
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів