Всем привет. Сегодня хочу рассказать, как я делала модальное окно на SwiftUI (в приложении, которое полностью пока написано на UIKit, за исключением новых фич) и какие возникли сложности, а так же как с ними справилась.
Вот дизайн, ничего необычного, по нажатию на TableViewCell мы видим модальное окно, в котором отображены имеющиеся сохраненные статьи, так же есть вариант получить с сервера новые статьи, отобразить progress view и затем опять вывести модальное окно с новой статьёй.
Казалось бы, что может пойти не так?
Давайте начнём…
Для начала создадим View и наполним её по дизайну:
import SwiftUI
struct ReportsModalView: View {
@Environment(\.presentationMode) var presentationMode
// Переменные
init() {
// Здесь инит
}
var body: some View {
VStack {
VStack(spacing: 0) {
setUpTopView()
setUpTextView()
setUpLikeShareButtons()
Divider()
HStack {
setupLimitButtonsView()
Spacer()
setupNextPreviousButtonsView()
}
.padding(.top, 16)
}
.padding()
.frame(maxWidth: .infinity, alignment: .bottom)
.background(
LinearGradient()
}
}
private func setUpTopView() -> some View {}
private func setUpTextView() -> some View {}
private func setUpDeleteAndQuestionView() -> some View {}
private func setUpLikeShareButtons() -> some View {}
private func setupNextPreviousButtonsView() -> some View {}
private func setupLimitButtonsView() -> some View {}
}
Не буду здесь расписывать иниты и прочие функции для отрисовки View, так как в данном контексте это не важно (но если всё же важно, то полный код есть на моём GitHub).
Дальше нам остаётся только вызвать эту View в нашем существующем UIViewController и наслаждаться новой фичей. Вызывается очень просто:
let swiftUIView = ReportsModalView()
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.modalPresentationStyle = .automatic
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.present(hostingController, animated: true, completion: nil)
}
Какой итог мы ожидаем - модальное окно как в UIKit, которое автоматически подстроится по высоте. Что мы получаем - модальное окно, которое по высоте всегда будет на весь экран... (специально подкрасила фон синим для наглядности). А так же скругленные края априори будут сверху, а не там, где начинается основной экран.
И вот тут меня ждало первое разочарование. Оказывается никак, никакими методами нельзя сделать такое же модальное окно, если вызывать его из UIKit. Дальше у меня ещё были попытки использовать какие-то сомнительные костыли, типа такого:
let bottomSheetView = ReportsView()
let hostingController = UIHostingController(rootView: bottomSheetView)
// Make sure the SwiftUI view has the correct intrinsic size
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
// Add the view temporarily to the view hierarchy (not visible) to measure its size
self.view.addSubview(hostingController.view)
hostingController.view.layoutIfNeeded()
// Calculate the target size based on the system layout fitting
let targetSize = hostingController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
hostingController.preferredContentSize = targetSize
// Remove the temporarily added view after calculation
hostingController.view.removeFromSuperview()
// Set the corner radius for the hosting controller's view
hostingController.view.layer.cornerRadius = 16
hostingController.view.layer.masksToBounds = true
hostingController.view.backgroundColor = UIColor(hex: "#EBF5FF")
if let sheet = hostingController.sheetPresentationController {
if #available(iOS 16.0, *) {
sheet.detents = [.custom(resolver: { _ in (targetSize.height) })]
} else {
// Fallback on earlier versions
sheet.detents = [.medium()]
}
}
present(hostingController, animated: true, completion: nil)
Тут было плохо примерно всё: View все равно не пересчитывалась по высоте, работало криво и через раз. Поэтому я довольно быстро бросила эту затею и начала думать уже что можно сделать с самой View. В какой-то момент я даже хотела плюнуть и сделать уже всё на UIKit, но вовремя опомнилась. Всё же рано или поздно все перейдут на SwiftUI (как это было с Objective-C) и это только вопрос времени. Поэтому было решено сделать маленький костыль, который легко убрать, когда основной UIViewController так же будет на SwiftUI.
Вот моё решение:
var body: some View {
VStack {
setUpTopView()
... контент без изменений
}
// Добавляем прозрачность для фона
.background(Color(white: 0, opacity: 0.4))
}
private func setUpTopView() -> some View {
return HStack {
... без изменений
}
// Добавляем RoundedRectangle в background
.background(
RoundedRectangle(
cornerRadius: 20,
style: .continuous
)
.fill(Color(UIColor(hex: "#ECEBFF")))
.frame(height: 64)
.frame(width: UIScreen.main.bounds.width)
.padding([.top], -64)
)
}
}
// В UIViewController:
let swiftUIView = ReportsModalView()
let hostingController = UIHostingController(rootView: swiftUIView)
// Добавим clear background и modalPresentationStyle - overFullScreen
hostingController.view.backgroundColor = .clear
hostingController.modalPresentationStyle = .overFullScreen
hostingController.hidesBottomBarWhenPushed = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.present(hostingController, animated: true, completion: nil)
}
В итоге получаем наше модальное окно:
Вот как-то так, легко и непринужденно встраиваем SwiftUI потихоньку в проект. Ладно, на самом деле есть некоторые сложности, в следующих частях покажу как делать ProgressView и SkeletonView.
Я даже сделала рилс на тему этой, на первой взгляд, быстрой фичи: https://t.me/NataWakeUp/434