iosxcodeswiftuiipaduikit

How to present a view with partial screen width on iPad and dismiss on background tap


I'm currently refactoring my iPad app from UIKit to SwiftUI and need help replicating a specific view presentation behavior.

Current Implementation:

I have a SwiftUI view (AccountDetailsView) that is presented from a UIKit view controller (HomeViewController) using UIHostingController. The AccountDetailsView takes up 2/3 of the view controller's width from the right side, and the remaining 1/3 of the screen is transparent. When the transparent area is tapped, the view dismisses. This is achieved by setting the transitioningDelegate and using a custom transition.

Refactoring:

I'm now refactoring HomeViewController to HomeView using SwiftUI and want to replicate the same behavior that AccountDetailsView should act like custom modal view.

account-details-view-presentation

let controller = UIHostingController(rootView: AccountDetailsView())
let size = CGSize(width: (view.bounds.width * 2 / 3).rounded(), height: UIScreen.main.bounds.height)

controller.preferredContentSize = size
controller.view.backgroundColor = .clear
controller.transitioningDelegate = SlideTransitioningController(direction: .right)
controller.modalPresentationStyle = .custom
present(controller, animated: true)

Here's a SlideTransitioningController implementation if it helps:

import UIKit

public final class SlideTransitioningController: NSObject, UIViewControllerTransitioningDelegate {

  var direction: PresentationDirection
  var backgroundType: BackgroundType
  var dismissOnTapOutside: Bool

  public init(
    direction: PresentationDirection = .left,
    background: BackgroundType = .blacked,
    dismissOnTapOutside: Bool = true
  ) {
    self.direction = direction
    self.backgroundType = background
    self.dismissOnTapOutside = dismissOnTapOutside
  }

  // MARK: - UIViewControllerTransitioningDelegate
  public func presentationController(
    forPresented presented: UIViewController,
    presenting: UIViewController?,
    source: UIViewController
  ) -> UIPresentationController? {
    SlideInPresentationController(
      presentedViewController: presented,
      presenting: presenting,
      direction: direction,
      backgroundType: backgroundType,
      dismissOnTapOutside: dismissOnTapOutside
    )
  }

  public func animationController(
    forPresented presented: UIViewController,
    presenting: UIViewController,
    source: UIViewController
  ) -> UIViewControllerAnimatedTransitioning? {
    SlideInPresentationAnimator(direction: direction, isPresentation: true)
  }

  public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    SlideInPresentationAnimator(direction: direction, isPresentation: false)
  }
}

// MARK: - Types
public extension SlideTransitioningController {
  enum PresentationDirection: Equatable {
    case left, top, right, bottom, center
  }

  enum BackgroundType {
    case blurred, blacked
  }
}

Solution

  • One approach is to embed your SwiftUI view within a UIKit UIHostingController, where you can add UIKit-specific features. After that, you can convert the UIHostingController back to a SwiftUI view using UIViewControllerRepresentable.

    Here’s an implementation example for reference:

    class SlideTransitionWrapper<Content: View>: UIViewController, UIPopoverPresentationControllerDelegate {
    
    private let content: () -> Content
    var onDismiss: (() -> Void)?
    var hostVC: UIHostingController<Content>?
    private lazy var slideTransitioningController = SlideTransitioningController(direction: .right, onDismiss: onDismiss)
    
    required init?(coder: NSCoder) { fatalError("") }
    
    init(content: @escaping () -> Content) {
        self.content = content
        super.init(nibName: nil, bundle: nil)
    }
    
    func show() {
        guard hostVC == nil else { return }
        let controller = UIHostingController(rootView: content())
        let size = CGSize(width: (UIScreen.main.bounds.width * 2 / 3).rounded(),
                          height: UIScreen.main.bounds.height)
    
        controller.preferredContentSize = size
        controller.view.backgroundColor = .clear
        controller.transitioningDelegate = slideTransitioningController
        controller.modalPresentationStyle = .custom
        hostVC = controller
        controller.presentationController?.delegate = self
        present(controller, animated: true)
    }
    
    func hide() {
        guard let vc = hostVC, !vc.isBeingDismissed else { return }
        dismiss(animated: true, completion: nil)
        hostVC = nil
    }
    
    func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
        hostVC = nil
        onDismiss?()
      }
    }
    
    struct SlideTransitionRepresentable<Content: View>: UIViewControllerRepresentable {
    @Binding var show: Bool
    let content: () -> Content
    
    func makeUIViewController(context: UIViewControllerRepresentableContext<SlideTransitionRepresentable<Content>>)
    -> SlideTransitionWrapper<Content> {
        let vc = SlideTransitionWrapper(content: content)
        vc.onDismiss = {
            vc.hostVC = nil
            show = false
        }
        return vc
    }
    
    func updateUIViewController(
        _ uiViewController: SlideTransitionWrapper<Content>,
        context: UIViewControllerRepresentableContext<SlideTransitionRepresentable<Content>>
    ) {
        show ? uiViewController.show() : uiViewController.hide()
      }
    }
    
    extension View {
        public func slideTransition<Content: View>(
            isPresented: Binding<Bool>,
            @ViewBuilder content: @escaping () -> Content
        ) -> some View {
            background(SlideTransitionRepresentable(show: isPresented, content: content))
        }
    }
    

    And usage of it be will be like below:

    .slideTransition(isPresented: $showSlideTransitioningView) {
          YourView()
    }