Last active
June 11, 2025 07:25
-
-
Save dch09/018c4d61dcbd33ffbee1980a5dc772ee to your computer and use it in GitHub Desktop.
Intrinsically sized SwiftUI View based UISheetPresentationController
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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