Implementing Action Sheet with a Custom View Content in SwiftUI & UIKit

By using the power of both UIKit and SwiftUI

Ihor Malovanyi
10 min readJan 19, 2024

It started when I saw Apple’s Journal app for the first time. The UI in the app is catastrophic, but I found an exciting component I had never seen before: an action Sheet with an Inline Date Picker inside. I inspected the component. Shame on Apple because it is fake. It is not an Action Sheet, but a very similar designed view with a swipe-to-rubber side effect.

Even though I didn’t like Apple’s implementation, I like the idea of using the Action Sheet not just with bland titles and messages but with various content!

If you like my content and want to support me, please buy me a coffee and subscribe!

Also, I have the Telegram channel Swift in UA, where you can find more exciting things.

Inspect Action Sheet UI

SwiftUI Action Sheet is a deprecated concept. Although it is still possible to use it via the .actionSheet modifier and ActionSheet objects inside, Apple recommends using the .confirmationDialog modifier instead.

.confirmationDialog provides an interface to set up the action sheet’s title, message, and actions. However, despite the actions parameter being designed as a @ViewBuilder, creating action buttons in a custom design is impossible.

Referring to the Apple documentation:

On iOS, tvOS, and watchOS, confirmation dialogs only support controls with labels that are Text. Passing any other type of view results in the content being omitted.

Default .confirmationDialog usage

SwiftUI View is a declarative model over UIKit’s View, so there is no flexible access to the view. What do I mean? To add something to a place not designed for this, it’s necessary to go down to the UIKit level.

.confirmationDialog and .actionSheet modifiers set an Action Sheet. But under the hood, they both use UIAlertController styled with UIAlertController.Style.actionSheet:

let sheetController = UIAlertController(title: nil,
message: nil,
preferredStyle: .actionSheet)

As part of UIKit, UIAlertController inherits UIViewController, so it has a view that we have access to. The first stop on the way to the new custom Action Sheet is a UIViewControllerRepresentable component that will encapsulate the logic we’ll design using UIKit!

Working with UIViewControllerRepresentable

Referring to the Apple documentation:

UIViewControllerRepresentable is view that represents a UIKit view controller. Use a UIViewControllerRepresentable instance to create and manage a UIViewController object in your SwiftUI interface.

Simply put, UIViewControllerRepresentable is a container for UIViewController that conforms to SwiftUI’s View. This container is like every other View in SwiftUI. Also, SwiftUI provides the same container for UIView: UIViewRepresentable. For implementing the custom action sheet component, it needs to use the UIViewControllerRepresentable to present a UIAlertController because it will be built in UIViewController attached to a hierarchy to present the sheet correctly.

struct CustomActionSheet<Content: View>: UIViewControllerRepresentable {

@Binding var isPresented: Bool
@ViewBuilder var content: () -> Content

func makeUIViewController(context: Context) -> UIViewController {
UIViewController()
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
presentActionSheet(uiViewController)
}
}

private func presentActionSheet(_ viewController: UIViewController) {
// ....... presentation logic
}

}

CustomActionSheet implements a UIViewControllerRepresentable. Methods makeUIViewController and updateUIViewController are required to implement. Content is a View that will be put at the top of the action sheet. Date picker, for example.

But what will be the core mechanic of this component? UIViewController needs to present the UIActionController when the property isPresented toggles to true. The updateUIViewController method is triggered every time the isPresented property is updated because it is bound to a state. Therefore, the method is the right place to present the action sheet.

Implementing Action Sheet with Custom View Content

Steps to implement an action sheet with custom content:

  • Convert content (SwiftUI’s view) to UIKit’s view using UIHostingViewController.
  • Creating UIAlertController.
  • Creating an action model.
  • Adding content view.
  • Layouting.
  • Presenting the completed Action Sheet.

Converting Content View to UIView using UIHostingViewController

Referring to the Apple documentation:

UIHostingViewController is a UIKit view controller that manages a SwiftUI view hierarchy. Create a UIHostingController object when you want to integrate SwiftUI views into a UIKit view hierarchy.

private func presentActionSheet(_ viewController: UIViewController) {
guard let contentView = UIHostingController(rootView: content()).view
else { return }
}

In this case, a hosting controller is unnecessary, but its view is required.

Creating UIAlertController

private func presentActionSheet(_ viewController: UIViewController) {
..........

let sheetController = UIAlertController(title: nil,
message: nil,
preferredStyle: .actionSheet)
}

There is no special action sheet component in UIKit. To create an action sheet, create an instance of UIAlertController and set the preferredStyle to UIAlertController.Style.actionSheet.

In the next step,add actions to the alert controller. UIAlertController works with UIAlertAction that has three parameters:

  • title — the text to use for the button title.
  • style — additional styling information to apply to the button. Style has 3 options: default, destructive, and default.
  • handler — block to execute when the user selects the action.

I am creating a component for SwiftUI, so using UIAlertAction on the SwiftUI level will be the wrong style. But I can’t use the origin confirmation dialog actions approach (@ViewBuilder) because parsing the view is too complicated and nonoptimal. So, it’s necessary to create a model for working with actions in my component.

Create an Action Model

The model will have 3 parameters: title, role (equal to UIAlertAction’s style), and handler:

struct SheetAction {

var title: String
var role: UIAlertAction.Style
var handler: (() -> ())?

}

In the next step, create a simple actions builder to stick to the general SwiftUI style:

@resultBuilder
struct SheetActionBuilder {

static func buildBlock(_ components: SheetAction...) -> [SheetAction] {
components
}

// You can implement other functions if you need

}

Finally, add the builder to the CustomActionSheet, transform the result models into UIAlertAction models, and add the models to the UIAlertController:

struct CustomActionSheet<Content: View>: UIViewControllerRepresentable {

..........
@SheetActionBuilder var actions: () -> [SheetAction]

..........

private func presentActionSheet(_ viewController: UIViewController) {
..........

let actions = actions()
actions
.map { action in
UIAlertAction(title: action.title,
style: action.role) { _ in
action.handler?()
isPresented = false
}
}
.forEach(sheetController.addAction)
}

}

In the handler, perform action’s handler and then toggle `isPresented` to false because the general behavior of an action sheet is to close itself on selection.

Note: .default and .destructive roles describe the general actions in the action sheet. But the .cancel role is different. In the action sheet, cancel is a separate button. Also, it’s impossible to have more than one cancel button in an action sheet, and if an action sheet has a cancel button, it has achieved additional behavior: dismiss an action sheet on tap outside. That’s why I made the handler optional, and that’s why I added isPresented toggling logic to UIAlertAction.

Layouting

Before starting layouting, inspect the UIAlertController using the Debug View Hierarchy tool.

What I noticed:

  • The height of the button is 57.
  • The gap between the cancel button and the other buttons is 8.
  • The separator’s height depends on the presence of a cancel button inside: if it is — 0.5, if not — 0.33.
  • The buttons section is pinned to the bottom. So, if I stretch the view, it does not affect the buttons’ position.

The idea is to add a content view to the UIAlertController’s view and pin it to the top.

private func presentActionSheet(_ viewController: UIViewController) {
..........

guard let sheetView = sheetController.view
else { return }

sheetView.addSubview(contentView)
contentView.translatesAutoresizingMaskIntoConstraints = false
contentView.backgroundColor = .clear

let hasCancel = actions.contains { $0.role == .cancel }
let actionsCount = actions.filter { $0.role != .cancel }.count
let actionButtonHeight: CGFloat = 57
let toCancelGap: CGFloat = 8
let buttonsSectionHeight: CGFloat = actionButtonHeight * CGFloat(actionsCount) + (hasCancel ? actionButtonHeight + toCancelGap : 0)

contentView.topAnchor.constraint(equalTo: sheetView.topAnchor).isActive = true
contentView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor).isActive = true
contentView.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor).isActive = true
contentView.bottomAnchor.constraint(equalTo: sheetView.bottomAnchor, constant: -buttonsSectionHeight).isActive = true
}

Add contentView to sheetView and set the constraints to pin it to the top and horizontally. Then, use the bottom anchor constraint to stretch the sheetView to the buttonsSectionHeight (based on the information I noticed above). So, now the sheetView size equals the buttonsSectionHeight + contentView.height.

The final step in the section is adding the separator between the buttons section and contentView. The separator must look like other action sheet separators:

private func presentActionSheet(_ viewController: UIViewController) {
..........

if actionsCount > 0 {
let separator = UIView()
separator.backgroundColor = .separator
separator.translatesAutoresizingMaskIntoConstraints = false
sheetView.addSubview(separator)

separator.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor).isActive = true
separator.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor).isActive = true
separator.topAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
separator.heightAnchor.constraint(equalToConstant: hasCancel ? 0.5 : 0.33).isActive = true
}
}

Presenting the completed Action Sheet

Now the action sheet component is done. It’s time to present UIAlertController from UIViewController:

private func presentActionSheet(_ viewController: UIViewController) {
..........

viewController.present(sheetController, animated: true)
}

Finally, update the updateUIViewController method:

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
presentActionSheet(uiViewController)
} else {
uiViewController.presentedViewController?.dismiss(animated: true)
}
}

Note: it’s better to move logic from presentActionSheet method to Coordinator, but now I created all in one place for simpler demo.

That’s it! The component is completed now! But I want to use it in the same way as the system’s confirmation dialog. What do I need to implement this?

Creating View Modifier

First, create the CustomActionSheetModifier that conforms ViewModifier:

struct CustomActionSheetModifier<C: View>: ViewModifier {

@Binding var isPresented: Bool
@ViewBuilder var content: () -> C
@SheetActionBuilder var actions: () -> [SheetAction]

func body(content: Content) -> some View {
ZStack {
CustomActionSheet(isPresented: $isPresented,
content: self.content,
actions: actions)
.frame(width: .zero, height: .zero)
content
}
}

}

The interface of the modifier is the same as in the CustomActionSheet component. The modifier does 2 things:

  • Places the CustomActionSheet below the caller view content.
  • Transits its property to create the CustomActionSheet.

Don’t be aware of the action sheet’s size. It’s here to attach its UIViewController to the presentation flow.

In the next step, extend the view with a new function to use the modifier conveniently:

extension View {

@available(iOS 13.0, *)
func confirmationDialog<C: View>(isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> (C),
@SheetActionBuilder actions: @escaping () -> ([SheetAction])) -> some View {
modifier(CustomActionSheetModifier(isPresented: isPresented, content: content, actions: actions))
}

}

I made the function available only for iOS because other platforms might implement the UI differently.

At the end of implementation, I recommend you change your code privacy. Keep public just the extension as the most convenient way to use the component.

Adopting Action Sheet for iPad

What happens if I try to open the action sheet on the iPad? Crash!

Referring to the Apple documentation:

In regular size classes in iOS, the system renders alert sheets as a popover that the user dismisses by tapping anywhere outside the popover, rather than displaying the default dismiss button.

So, it’s necessary a minor update for the iPad case:

private func presentActionSheet(_ viewController: UIViewController) {
guard let contentView = UIHostingController(rootView: content()).view
else { return }

let sheetController = UIAlertController(title: nil,
message: nil,
preferredStyle: .actionSheet)

let isPad = UIDevice.current.userInterfaceIdiom == .pad
if isPad {
sheetController.popoverPresentationController?.sourceView = viewController.view
}

..........
}

The action sheet presented from the UIViewController size is the same size as the CustomActionSheet component.

So, now it works but has two problems: the button layout and the presentation arrow pin.

To fix the button layout, let’s see the difference between iOS and iPadOS when presenting the action sheet: the popover action sheet’s variant doesn’t draw a cancel button! But it will fire when the user taps outside the popover.

Let’s fix computations:

private func presentActionSheet(_ viewController: UIViewController) {
..........

let hasCancel = isPad ? false : actions.contains { $0.role == .cancel }
..........
let separatorHorizontalGap: CGFloat = isPad ? 8 : 0

..........
if actionsCount > 0 {
..........
separator.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: separatorHorizontalGap).isActive = true
..........
}
..........
}

To fix the popover arrow’s position, update the CustomActionSheetModifier:

struct CustomActionSheetModifier<C: View>: ViewModifier {

@Binding var isPresented: Bool
@ViewBuilder var content: () -> C
@SheetActionBuilder var actions: () -> [SheetAction]

@State private var size: CGSize = .zero

func body(content: Content) -> some View {
ZStack {
CustomActionSheet(isPresented: $isPresented,
content: self.content,
actions: actions)
.frame(width: size.width, height: size.height)
content.background {
GeometryReader { proxy in
Color.clear.preference(key: CustomActionSheetSizeKey.self,
value: proxy.size)
}
}
}
.onPreferenceChange(CustomActionSheetSizeKey.self) { size in
self.size = size
}
}

}

struct CustomActionSheetSizeKey: PreferenceKey {

static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}

}

Using PreferenceKey and GeometryReader, I observe the content size and set the same size to my action sheet. Now it works perfectly!

That’s it! The component is done and ready to use with a wide range of content views! It is a little demo of SwiftUI + UIKit power. I insist that every successful iOS developer must use both!

--

--