В этой статье я расскажу о том, как создать нативный Sheet, который автоматически подсчитывает свою высоту в зависимости от контента (SwiftUI View). Задача была в том, чтобы решение было c минимумом костылей и сохраняло поддержку iOS 15. Готового похожего решения мне не удалось найти, поэтому решил создать свой вариант.
Задача
Создать DynamicSheet, который сам подсчитывает свою высоту в зависимости от контента в нем, которым является SwiftUI View, и поддерживает iOS 15
Описанный далее подход не претендует на идеальное или самое оптимальное решение и может иметь ограничения, особенно при будущих изменениях iOS. Далее описан лишь один из вариантов реализации

Что использовалось для реализации?
Для достижения поставленной задачи я использовал следующие инструменты, которые сделали решение гибким и универсальным:
UIKit обеспечил основу для создания кастомного
UISheetPresentationControllerс определенной высотой.UIViewControllerRepresentable для интеграции
UISheetPresentationControllerв SwiftUIUIHostingController для интеграции SwiftUI View в
UISheetPresentationControllerObjective-C был необходим для расширения возможностей
UISheetPresentationControllerDetent, что позволило задавать детенты с произвольной высотой, недоступные через стандартный API.Custom EnvironmentKey дал возможность реализовать способ закрытия Sheet, т.к. с использованием
@Environment(\.dismiss)возникли проблемыSystemLayoutSizeFitting использовался для вычисления минимальной высоты контента
Как все работает?
DynamicSheet принимает на вход SwiftUI View.
SwiftUI View, который вы хотите отобразить вDynamicSheet, передаётся вDynamicSheetHelper.DynamicSheetHelperреализуетUIViewControllerRepresentable. Этот протокол позволяет использоватьUISheetPresentationController, который доступен только в UIKit. Он является мостом между SwiftUI и UIKit.DynamicSheetHelperпередает содержимое шторки (Swift UI View) вDynamicSheetHostingController.-
DynamicSheetHostingController(наследникUIHostingController) оборачивает содержимоеDynamicSheetи управляет:Динамическим вычислением высоты с помощью
systemLayoutSizeFitting.Настройкой детентов через приватный метод
_detentWithIdentifier:constant:.Кастомизацией, включая фон и радиус скругления углов.
Проблемы и их решения
1. Динамический подсчёт высоты
Одной из ключевых задач было корректное определение высоты для DynamicSheet на основе содержимого SwiftUI View. Для этого использовался метод systemLayoutSizeFitting, который рассчитывает подходящий размер представления:
if let presentationController = presentationController as? UISheetPresentationController {
let fittingSize = view.systemLayoutSizeFitting(view.frame.size)
preferredContentSize = fittingSize
}
Этот подход позволяет автоматически подсчитать высоту под содержимое, даже в случаях с вложенными представлениями или при использовании разных размеров экранов. Метод systemLayoutSizeFitting определяет минимально необходимый размер, обеспечивая корректное отображение без лишнего пространства.
2. Проблема с приватным API для постоянной высоты Sheet
Для управления высотой UISheetPresentationController на iOS 15 я использовал приватный метод _detentWithIdentifier:constant:. В iOS 15 в Swift базово поддерживает только .medium и .large детенты, что не подходит для моей задачи. Чтобы обойти это ограничение, пришлось добавить Objective-C заголовочный файл со следующим содержимым:
#import
NS_ASSUME_NONNULL_BEGIN
@interface UISheetPresentationControllerDetent (Private)
+ (UISheetPresentationControllerDetent *)_detentWithIdentifier:(NSString *)identifier constant:(CGFloat)constant;
@end
NS_ASSUME_NONNULL_END
Этот код расширяет UISheetPresentationControllerDetent и предоставляет метод для создания кастомного детента с указанной высотой.
Стоит отметить, что использование приватного метода связано с рисками. Такие методы не документированы, их поведение может измениться или они могут быть удалены в будущих версиях iOS.
Его использование:
presentationController.detents = [
._detent(withIdentifier: UUID().uuidString, constant: fittingSize.height)
]
3. Проблема с Environment(.dismiss)
Использовать стандартный метода закрытия Sheet через Environment(\.dismiss) не получилось. Возникли серьёзные ограничения: повторное открытие Sheet после его закрытия не работает.
Для быстрого решения этой проблемы был добавлен собственный EnvironmentKey, предоставляющий возможность управлять закрытием Sheet через dismissDynamicSheet. Такой подход обеспечивает полный контроль над состоянием отображения и позволяет избежать проблем с повторным открытием.
Библиотека с готовым компонентом
В статье описаны только основные технические моменты для реализации. Код полностью готового компонента можно найти в библиотеке. Она находится в репозитории на GitHub. Далее будут описаны примеры ее использования.
Пример использования
Базовый пример
import DynamicSheet
import SwiftUI
struct ContentView: View {
@State private var isPresented = false
var body: some View {
Button("Показать Sheet") {
isPresented.toggle()
}
.dynamicSheet(
isPresented: $isPresented,
backgroundColor: .green
) {
SheetView()
}
}
}
struct SheetView: View {
var body: some View {
VStack {
Text("Hello")
.font(.largeTitle)
.padding()
}
}
}
Пример с закрытием через кастомный EnvironmentKey
import DynamicSheet
import SwiftUI
struct ContentView: View {
@State private var isPresented = false
var body: some View {
Button("Показать Sheet") {
isPresented.toggle()
}
.dynamicSheet(isPresented: $isPresented) {
SheetView()
}
}
}
struct SheetView: View {
@Environment(\.dismissDynamicSheet) private var dismiss
var body: some View {
VStack {
Button(action: { dismiss?() }) {
Text("Dismiss")
.font(.title)
.padding()
.background(Color.purple)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.padding()
}
}

Дополнительные возможности
Вы можете изменить фон Sheet, задав его через параметр
backgroundColorпри вызовеdynamicSheet.
Особенности:
Если внутри
dynamicSheetиспользуетсяScrollView, то он автоматически растянется на весь экран.
Заключение
Несмотря на то, что компонент получился довольно небольшим, я решил оформить его как Swift Package. Вы можете подключить библиотеку или взять тех-идею и повторить её в своём проекте с нужной кастомизацией.
Код библиотеки доступен в репозитории на GitHub. Если у вас возникли вопросы или предложения, вы можете связаться со мной через удобный способ из профиля GitHub или создать issue в репозитории. Буду рад вашей обратной связи!