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

Задача

Создать DynamicSheet, который сам подсчитывает свою высоту в зависимости от контента в нем, которым является SwiftUI View, и поддерживает iOS 15

Описанный далее подход не претендует на идеальное или самое оптимальное решение и может иметь ограничения, особенно при будущих изменениях iOS. Далее описан лишь один из вариантов реализации

Что использовалось для реализации?

Для достижения поставленной задачи я использовал следующие инструменты, которые сделали решение гибким и универсальным:

  • UIKit обеспечил основу для создания кастомного UISheetPresentationController с определенной высотой.

  • UIViewControllerRepresentable для интеграции UISheetPresentationController в SwiftUI

  • UIHostingController для интеграции SwiftUI View в UISheetPresentationController

  • Objective-C был необходим для расширения возможностей UISheetPresentationControllerDetent, что позволило задавать детенты с произвольной высотой, недоступные через стандартный API.

  • Custom EnvironmentKey дал возможность реализовать способ закрытия Sheet, т.к. с использованием @Environment(\.dismiss) возникли проблемы

  • SystemLayoutSizeFitting использовался для вычисления минимальной высоты контента

Как все работает?

  1. DynamicSheet принимает на вход SwiftUI View. SwiftUI View, который вы хотите отобразить в DynamicSheet, передаётся в DynamicSheetHelper.

  2. DynamicSheetHelper реализует UIViewControllerRepresentable. Этот протокол позволяет использовать UISheetPresentationController, который доступен только в UIKit. Он является мостом между SwiftUI и UIKit. DynamicSheetHelper передает содержимое шторки (Swift UI View) в DynamicSheetHostingController.

  3. 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-ы из примеров
Sheet-ы из примеров

Дополнительные возможности

  1. Вы можете изменить фон Sheet, задав его через параметр backgroundColor при вызове dynamicSheet.

Особенности:

  1. Если внутри dynamicSheet используется ScrollView, то он автоматически растянется на весь экран.

Заключение

Несмотря на то, что компонент получился довольно небольшим, я решил оформить его как Swift Package. Вы можете подключить библиотеку или взять тех-идею и повторить её в своём проекте с нужной кастомизацией.

Код библиотеки доступен в репозитории на GitHub. Если у вас возникли вопросы или предложения, вы можете связаться со мной через удобный способ из профиля GitHub или создать issue в репозитории. Буду рад вашей обратной связи!

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