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

Что использовалось для реализации?
Для достижения поставленной задачи я использовал следующие инструменты, которые сделали решение гибким и универсальным:
UIKit обеспечил основу для создания кастомного
UISheetPresentationController
с определенной высотой.UIViewControllerRepresentable для интеграции
UISheetPresentationController
в SwiftUIUIHostingController для интеграции SwiftUI View в
UISheetPresentationController
Objective-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 в репозитории. Буду рад вашей обратной связи!