Всем привет. Сегодня хочу рассказать, как я делала модальное окно на 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

Комментарии (2)


  1. storoj
    12.10.2024 18:54

    А как на телефоне можно понять что за "бесполе..."? Навести мышкой, чтобы увидеть подсказку, не получится.


    1. kosyakus Автор
      12.10.2024 18:54

      Хорошее замечание, так как я тренировалась и делала эту фичу в отдельном проекте, я не сильно замораживалась с UI частью. А так в идеале либо подстраиваться под ширину экрана и уменьшать шрифт, либо же как у нас в итоге сделано - просто оставлять только значки без текста, если размер экрана не позволяет полностью надпись вставить.