Использование SwiftUI в UIKit

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

Недавно мы начали активно использовать в проектах SwiftUI. Большинство из этих проектов должны быть совместимы с iOS 13, поэтому мы решили, что будет неразумно писать все приложение в SwiftUI. Первая итерация была слишком “сырой”, особенно по части навигации, и было бы слишком рискованно поставлять все приложение, полностью написанное с помощью SwiftUI. Более того, нам не хотелось тратить все наработки, созданные в UIKit. Поэтому мы решили пойти на смешивание этих инструментов.

Добавить SwiftUI в UIKit несложно, если воспользоваться простым расширением. Но начнем с нашего примера.

Весь готовый проект можно найти на моей странице Patreon.

Цель

Основная идея заключается в создании небольших представлений в SwiftUI и их внедрении в контроллеры, или представления, UIKit, а именно в канонический UIViewControllers, находящийся в проекте Storyboard, передавая данные между этими двумя компонентами.

Представление SwiftUI

Создадим простое представление. Массив поддерживающих нажатие элементов Text с переключаемым цветом фона:

import SwiftUI

struct MyView: View {

@State var items: [String]
@State private var buttonStates: [String : Bool] = [:]

func getStateFor(_ key: String) -> Bool {
buttonStates[key] ?? false
}

var body: some View {
HStack {
ForEach(items, id:\.self) { itm in
Text(itm)
.frame(minWidth: 120)
.padding()
.background(getStateFor(itm) ? Color.green.gradient : Color.orange.gradient)
.cornerRadius(10)
.shadow(radius: 10)
.onTapGesture {
let state = getStateFor(itm)
buttonStates[itm] = !state
print(itm)
}
}
}
.background(.clear)
}
}

struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView(items: ["First", "Second"])
}
}

Очень примитивное представление, но это не важно.

Вот результат:

Магические расширения

Добавить представление SwiftUI в UIViewController довольно легко  — достаточно проделать ряд конкретных шагов.

  • Создать в представлении SwiftUI UIHostingController в качестве его rootView.
  • Установить этот контроллер в качестве чистого фона.
  • Добавить в базовое представление UIKit UIViewController представление UIHostingController.
  • Заполнить все доступное пространство с помощью autolayout.

Последовательность действий всегда одна и та же, поэтому мы можем написать extension для View и затем просто его переиспользовать.

import SwiftUI

extension View {

func injectIn(controller vc: UIViewController) {
let controller = UIHostingController(rootView: self)
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
vc.view.addSubview(controller.view)

NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
controller.view.topAnchor.constraint(equalTo: vc.view.topAnchor),
controller.view.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor)
])
}

}

Код получается довольно простым и, на мой взгляд, не требует пояснения.

Теперь пора его использовать, внедрив представление SwiftUI в контроллер основного представления.

Откроем класс UIViewController (напомню, что мы работаем над проектом, созданным с помощью Storyboards) и просто изменим код во viewDidLoad:

import UIKit

class ViewController: UIViewController {

var myView: MyView?

override func viewDidLoad() {
super.viewDidLoad()
// Релаизуем магию!
myView = MyView(items: ["First", "Second"])
myView?.injectIn(controller: self)
}
}

Получится такой результат:

Все работает, но можно его улучшить. Сейчас представление SwiftUI занимает все пространство UIViewController, но зачастую нам нужно внедрить компонент только в часть экрана, возможно, используя дополнительные представления SwiftUI в одном контроллере. Решение здесь простое  —  мы добавим еще одно расширение для внедрения на этот раз представления SwiftUI в представление UIKit:

import SwiftUI

extension View {

func injectIn(view: UIView) {
let controller = UIHostingController(rootView: self)
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
view.addSubview(controller.view)

NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}

Это расширение очень похоже на предыдущее. Применим его.

Добавьте UIView в Storyboard, установите его ограничения (картинка ниже) и подключите в качестве IBOutlet в контроллере представлений.

А вот код контроллеров представлений:

import UIKit

class ViewController: UIViewController {

@IBOutlet weak var buttonsView: UIView!
var myView: MyView?

override func viewDidLoad() {
super.viewDidLoad()
// Выполнение дополнительных настроек после загрузки представления.
myView = MyView(items: ["First", "Second"])
myView?.injectIn(view: buttonsView)
buttonsView.backgroundColor = .clear
}
}

Обратите внимание на строку 13, где я устанавливаю цвет фона на .clear , чтобы скрыть любой присутствующий фон UIView. Вот результат:

Получение данных из представления SwiftUI

Тем не менее недостаточно просто отражать представление SwiftUI в представлении UIKit. В реальном компоненте вам наверняка потребуется получать от него данные и использовать их в контроллере основного представления.

Для этого нужно просто добавить в представление SwiftUI замыкание (строки 8 и 26), после чего все заработает:

import SwiftUI

struct MyView: View {

@State var items: [String]
@State private var buttonStates: [String : Bool] = [:]

let onTap: (String) -> Void

func getStateFor(_ key: String) -> Bool {
buttonStates[key] ?? false
}

var body: some View {
HStack {
ForEach(items, id:\.self) { itm in
Text(itm)
.frame(minWidth: 120)
.padding()
.background(getStateFor(itm) ? Color.green.gradient : Color.orange.gradient)
.cornerRadius(10)
.shadow(radius: 10)
.onTapGesture {
let state = getStateFor(itm)
buttonStates[itm] = !state
onTap(itm)
}
}
}
.background(.clear)
}
}

struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView(items: ["First", "Second"]) { _ in }
}
}

Затем измените контроллер представлений:

import UIKit

class ViewController: UIViewController {

@IBOutlet weak var buttonsView: UIView!
var myView: MyView?

override func viewDidLoad() {
super.viewDidLoad()

myView = MyView(items: ["First", "Second"]) { selectedString in
print(selectedString)
}

myView?.injectIn(view: buttonsView)
buttonsView.backgroundColor = .clear
}
}

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Alessandro Manilii: Using SwiftUI in UIKit

Предыдущая статьяНа что способен Selenium в паре с JavaScript?
Следующая статьяSQL — язык программирования? 10 аргументов “за” и “против”