Skip to content

Instantly share code, notes, and snippets.

@dch09
Last active June 11, 2025 07:25
Show Gist options
  • Select an option

  • Save dch09/018c4d61dcbd33ffbee1980a5dc772ee to your computer and use it in GitHub Desktop.

Select an option

Save dch09/018c4d61dcbd33ffbee1980a5dc772ee to your computer and use it in GitHub Desktop.
Intrinsically sized SwiftUI View based UISheetPresentationController
//
// DynamicSheetController.swift
//
//
// Created by Daniel Choroszucha on 09/05/2024.
//
import SwiftUI
import UIKit
@available(iOS 16, *)
public class DynamicSheetController: UIViewController {
private let dynamicDetentIdentifier = UISheetPresentationController.Detent.Identifier("dynamic-detent")
public var backgroundColor: UIColor = .white
public var preferredCornerRadius: CGFloat = 14
public var prefersGrabberVisible: Bool = false
public var isDismissable: Bool = true
public var detents: [UISheetPresentationController.Detent]?
public var largestUndimmedDetent: UISheetPresentationController.Detent?
private var didLayoutSubviews = false
private var hostingController: UIHostingController<AnyView>?
public var onDismiss: (() -> Void)?
public init(
backgroundColor: UIColor = .white,
preferredCornerRadius: CGFloat = 14,
prefersGrabberVisible: Bool = false,
isDismissable: Bool = true,
detents: [UISheetPresentationController.Detent]? = nil,
largestUndimmedDetent: UISheetPresentationController.Detent? = nil,
onDismiss: (() -> Void)? = nil
) {
self.backgroundColor = backgroundColor
self.preferredCornerRadius = preferredCornerRadius
self.prefersGrabberVisible = prefersGrabberVisible
self.isDismissable = isDismissable
self.detents = detents
self.largestUndimmedDetent = largestUndimmedDetent
self.onDismiss = onDismiss
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Public Logic
public func show<V: View>(
view: V,
from viewController: UIViewController,
onDismiss: (() -> Void)? = nil
) {
if let dismissCallback = onDismiss {
self.onDismiss = dismissCallback
}
prepareHostingController()
let hostingController = hostSwiftUIView(view: AnyView(view))
hostingController.sizingOptions = .intrinsicContentSize
self.hostingController = hostingController
setupStyle(with: hostingController)
setupDetents(with: hostingController)
viewController.present(self, animated: true)
}
public func updateSheetSize() {
guard let hostingController = hostingController else { return }
/// Force layout update
hostingController.view.setNeedsLayout()
hostingController.view.layoutIfNeeded()
/// Recalculate size
setupDetents(with: hostingController)
}
override public func viewDidLayoutSubviews() {
guard !didLayoutSubviews else { return }
super.viewDidLayoutSubviews()
didLayoutSubviews = true
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
view.setNeedsDisplay()
}
// MARK: - Internal Logic
private func prepareHostingController() {
hostingController?.removeFromParent()
hostingController?.view.removeFromSuperview()
}
private func setupStyle<V: View>(with hostingController: UIHostingController<V>) {
view.backgroundColor = backgroundColor
isModalInPresentation = !isDismissable
sheetPresentationController?.preferredCornerRadius = preferredCornerRadius
sheetPresentationController?.prefersGrabberVisible = prefersGrabberVisible
}
private func setupDetents<V: View>(with hostingController: UIHostingController<V>) {
let screenWidth = UIScreen.main.bounds.width
let availableWidth = screenWidth - 32 // Account for horizontal padding
let targetSize = CGSize(width: availableWidth, height: UIView.layoutFittingExpandedSize.height)
let naturalSize = hostingController.sizeThatFits(in: targetSize)
let paddedHeight = naturalSize.height
let maxHeight = UIScreen.main.bounds.height * 0.85
let adjustedHeight = min(paddedHeight, maxHeight)
sheetPresentationController?.detents = getDetents(dynamicHeight: adjustedHeight)
sheetPresentationController?.largestUndimmedDetentIdentifier = largestUndimmedDetentIdentifier(for: adjustedHeight)
}
private func dynamicDetent(with height: CGFloat) -> UISheetPresentationController.Detent {
.custom(identifier: dynamicDetentIdentifier) { _ in height }
}
private func getDetents(dynamicHeight: CGFloat) -> [UISheetPresentationController.Detent] {
var allDetents: [UISheetPresentationController.Detent] = [dynamicDetent(with: dynamicHeight)]
if let detents {
allDetents.append(contentsOf: detents)
return allDetents
} else {
return allDetents
}
}
private func largestUndimmedDetentIdentifier(for dynamicHeight: CGFloat) -> UISheetPresentationController.Detent.Identifier? {
if let largestUndimmedDetent {
return largestUndimmedDetent.identifier
} else {
return dynamicDetent(with: dynamicHeight).identifier
}
}
}
public extension DynamicSheetController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
onDismiss?()
}
}
private extension UIView {
func addSwiftUIView<T: View>(view: T) -> UIHostingController<T> {
let hostingController = UIHostingController(rootView: view)
addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor)
])
return hostingController
}
func anchorToSuperView() {
guard let superview else { return }
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topAnchor.constraint(equalTo: superview.topAnchor),
leftAnchor.constraint(equalTo: superview.leftAnchor),
rightAnchor.constraint(equalTo: superview.rightAnchor),
bottomAnchor.constraint(equalTo: superview.bottomAnchor)
])
}
}
private extension UIViewController {
@available(iOS 16, *)
@discardableResult
func hostSwiftUIView<T: View>(
view: T,
insideView hostView: UIView? = nil
) -> UIHostingController<T> {
let hostingController = UIHostingController(rootView: view)
addChild(hostingController)
if let hostView {
hostView.addSubview(hostingController.view)
} else {
self.view.addSubview(hostingController.view)
}
hostingController.view.anchorToSuperView()
hostingController.didMove(toParent: self)
return hostingController
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment