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

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


  1. storoj
    12.10.2024 18:54

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


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

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


  1. Kskjsjsibbzjh
    12.10.2024 18:54

    Можно сделать иначе. (пример из моего проекта) В основном окне сделать Sheet

     .blur(radius: isPressedSheet3 ? 5 :  0) // для размытия основного окна. Можно заменить на затемнение. или наложить так же overlay с чем угодно

    .animation(.easeOut(duration: 0.2), value: isPressedSheet3) ///планое размытие

    .sheet(isPresented: $isPressedSheet3, content: {

                    ListTimer(isPressedSheet3: $isPressedSheet3, isShowTimer: $isShowTimer)

                .presentationDetents([.height(HeightListTimer) ,.large])

                .presentationDragIndicator(.hidden)

            })

    Само всплывающее окно

    VStack{

    // код

    }

                .background(

                    GeometryReader { geometry2 in

                        Color.clear

                            .onAppear {

                                print("Размер VStack: \(geometry2.size)")

                                HeightListTimer = Double(geometry2.size.height)

                            }

                    }

                )

    GeometryReader расположен в бекграунде. тк если его накладывать на все окно размер получается на весь экран а не на нужное нам VStack. Но если размеры известны заранее то можно передавать сразу их через

     .onAppear { HeightListTimer = 350 // известная высота }

    А так же в главном и всплывающем вью нужно поместить переменную. я сделал через:

    @AppStorage("HeightListTimer") private var HeightListTimer: Double = 400

    Можно и другими методами но так вышло проще


  1. fury21
    12.10.2024 18:54

    Не вижу никакой сложности сделать динамическое по высоте вью, как в дизайне на UIKit и не костылять в SUI. Думаю, вам не подходит презентовать целый контроллер, по дизайну лучше подходит кастомная вью. Единственное, что не понятно, как между собой связаны вью с индикатором загрузки и вью с текстом. Это одно вью с какой-то анимацией перехода или вью с индикатором загрузке должно скрыться и вместо него появиться вью с текстом (скелетоном). И не очень понятно, зачем вам вью с индикатором загрузки, а потом еще вью со скелетоном (та же загрузка), выглядит как дизайнерский просчет. Я бы оставил что-то одно и скорей всего это было бы второе вью со скелетоном, т.к. не понятно  к чему должна быть привязана загрузка с прогрессбаром или там рандомная анимация?


    Если очень хочется использовать модальное окно, его  рутовую вью можно сделать прозрачной и закруглить углы уже во вложенном вью, но в таком случае будет не очень красиво выглядеть анимация заблюренной вью, которая снизу-вверх показывается. 


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

      Вот вы и раскрыли интригу второй части)) да, действительно нет смысла иметь одновременно скелетон и прогресс вью. Они взаимозаменяемые. Но я думаю всё равно покажу как их делала.

      Приложение довольно активно переходит на SwiftUI, поэтому всё же думаю небольшие костыли оправданы, с учётом того, что они уберутся впоследствии. Но в целом да, если бы не переход, то конечно, имел бы смысл сделать всё на UIKit.


  1. fury21
    12.10.2024 18:54

    Видео к посту в ТГ добавил


  1. spiceginger
    12.10.2024 18:54

    А вы читаете нормальным что у вам модальное окно празднуется вместе с затемненным слоем? См последний гиф.