While building a fully SwiftUI app I needed a fully customizable modal dialog that I could present things in with as little effort as possible. So I built it.

SwiftUI is immensely powerful and you can use it to build pretty much anything you desire. It turns out that modal dialogs are no exception.

UPDATE: I made a library using the code from this blog post and some more improvements. You can find it in the repository below.

https://github.com/jankaltoun/CustomModalView

The task

First of all, let's take a look at what we want to achieve visually. The gif below shows the default modal dialog style that we're going to implement. We will also make it fully customizable.

Modal Dialog in action

Second, let's say that we want the modal to be as close to Apple-provided APIs as possible.

For instance when using the sheet modifier to display, well, a sheet... We can simply call it as follows.

.sheet(isPresented: $sheetIsDisplayed) {
    Text("Hello world!")
}

In case of our modal I want exactly the same API to work. For example:

.modal(isPresented: $modalIsDisplayed) {
    Text("Hello world!")
        .padding()
}

Building the modal

The first thing we need to do is define our modal struct.

struct ModalView<Parent: View, Content: View>: View {
    @Environment(\.modalStyle) var style: AnyModalStyle
    
    @Binding var isPresented: Bool
    
    var parent: Parent
    var content: Content
    
    let backgroundRectangle = Rectangle()
    
    var body: some View {
        ZStack {
            parent
            
            if isPresented {
                style.makeBackground(
                    configuration: ModalStyleBackgroundConfiguration(
                        background: backgroundRectangle
                    ),
                    isPresented: $isPresented
                )
                style.makeModal(
                    configuration: ModalStyleModalContentConfiguration(
                        content: AnyView(content)
                    ),
                    isPresented: $isPresented
                )
            }
        }
        .animation(style.animation)
    }
    
    init(isPresented: Binding<Bool>, parent: Parent, @ViewBuilder content: () -> Content) {
        self._isPresented = isPresented
        self.parent = parent
        self.content = content()
    }
}

This is quite complicated so let's break it down...

struct ModalView<Parent: View, Content: View>: View {

Our Modal Dialog is a View that is generic over:

  • Parent View - the View we're creating the modal on and that will be wrapped with a ZStack to create the overlay
  • Content View - the user-defined Modal contents

@Environment(.modalStyle) var style: AnyModalStyle

We want the Modal dialog to be stylable as are for example Buttons or Lists. To achieve this we need to pass the style as an environment variable to our Modal View instance. For now let's just assume we can provide the style to any Modal dialog simply by calling .modalStyle(SomeModalStyle()). We will talk about this later.

var body: some View {
    ZStack {
        parent
        
        if isPresented {
            style.makeBackground(
                configuration: ModalStyleBackgroundConfiguration(
                    background: backgroundRectangle
                ),
                isPresented: $isPresented
            )
            style.makeModal(
                configuration: ModalStyleModalContentConfiguration(
                    content: AnyView(content)
                ),
                isPresented: $isPresented
            )
        }
    }
    .animation(style.animation)
}

The body is simply a ZStack that wraps:

  • Parent View
  • Background View (to dim the background of the Modal)
  • Modal View (the contents of the Modal dialog)

It also accepts an optional Animation to be performed when the Modal dialog appears and disappears.

Notice that both the Background and Modal views as well as the animation are taken from the style mentioned above. We will get to this soon but for now let's assume that the makeBackground and makeModal functions are simply factories that accept a configuration object that provides parameters needed to construct the respective Views. The animation is just a value.

init(isPresented: Binding<Bool>, parent: Parent, @ViewBuilder content: () -> Content) {
    self._isPresented = isPresented
    self.parent = parent
    self.content = content()
}

To initialize our Modal we need three things:

  • Binding to the State defining whether the Modal should be displayed or not
  • The Parent View
  • The Content View

This is all quite self-explanatory except for the _isPresented property. This "magic" property is generated by the compiler because of the @Binding property-wrapper in @Binding var isPresented: Bool. It contains the Binding<Bool> value that we need to set.

For more information see the Property Wrappers Swift evolution proposal.

Notice that the content parameter is annotated with @ViewBuilder. This tells the compiler to expect a ViewBuilder function builder closure that creates some View. Imagine ZStack { ... } - it's exactly the same. Of course we need to actually execute the closure to have the resulting View constructed.

Using the modal

As mentioned above we want to be able to construct the modal as follows.

.modal(isPresented: $modalIsDisplayed) {
    Text("Hello world!")
        .padding()
}

In order to allow such functionality, we need to create a View protocol extension.

extension View {
    func modal<ModalBody: View>(
            isPresented: Binding<Bool>,
            @ViewBuilder modalBody: () -> ModalBody
    ) -> some View {
        ModalView(
            isPresented: isPresented,
            parent: self,
            content: modalBody
        )
    }
}

This extension is quite self-explanatory as it mimics the initializer of the ModalView.

It is important though as having it will let us create the Modal on any View we want - by simply calling a function.

Styling the modal

To allow for customization of the modal we want to let users modify the apperance of both our Background and Content views.

Let's define a ModalStyle and its configuration structures.

struct ModalStyleBackgroundConfiguration {
    let background: Rectangle
}

struct ModalStyleModalContentConfiguration {
    let content: AnyView
}
protocol ModalStyle {
    associatedtype Background: View
    associatedtype Modal: View
    
    var animation: Animation? { get }
    
    func makeBackground(configuration: BackgroundConfiguration, isPresented: Binding<Bool>) -> Background
    func makeModal(configuration: ModalContentConfiguration, isPresented: Binding<Bool>) -> Modal
    
    typealias BackgroundConfiguration = ModalStyleBackgroundConfiguration
    typealias ModalContentConfiguration = ModalStyleModalContentConfiguration
}

This protocol is very similar to SwiftUI's ButtonStyle. It allows us to execute a function (well, two functions in our case) whenever a Modal dialog is created.

Remember the body property of ModalDialog where these functions are used as well as the animation parameter.

This is great as the makeBackground and makeModal functions will be run each time the Modal dialog contents are being created.

The problem is that the protocol has associatedtype requirements and so we cannot use it as a non-generic type.

This is why in the ModalDialog the environment variable is type-erased to AnymodalStyle.

@Environment(\.modalStyle) var style: AnyModalStyle

The AnyModalStyle struct is quite simple as really the only thing it does is that it erases types.

public struct AnyModalStyle: ModalStyle {
    let animation: Animation?
    
    private let _makeBackground: (ModalStyle.BackgroundConfiguration, Binding<Bool>) -> AnyView
    private let _makeModal: (ModalStyle.ModalContentConfiguration, Binding<Bool>) -> AnyView
    
    init<Style: ModalStyle>(_ style: Style) {
        self.animation = style.animation
        self._makeBackground = style.anyMakeBackground
        self._makeModal = style.anyMakeModal
    }
    
    func makeBackground(configuration: ModalStyle.BackgroundConfiguration, isPresented: Binding<Bool>) -> AnyView {
        return self._makeBackground(configuration, isPresented)
    }
    
    func makeModal(configuration: ModalStyle.ModalContentConfiguration, isPresented: Binding<Bool>) -> AnyView {
        return self._makeModal(configuration, isPresented)
    }
}

In other words it acts as a proxy between a typed style implementation and the ModalView. Notice that it delegates the makeBackground and makeModal calls to makeBackground and makeModal functions. These functions are defined in an extension of ModalStyle.

extension ModalStyle {
    func anyMakeBackground(configuration: BackgroundConfiguration, isPresented: Binding<Bool>) -> AnyView {
        AnyView(
            makeBackground(configuration: configuration, isPresented: isPresented)
        )
    }
    
    func anyMakeModal(configuration: ModalContentConfiguration, isPresented: Binding<Bool>) -> AnyView {
        AnyView(
            makeModal(configuration: configuration, isPresented: isPresented)
        )
    }
}

The only thing they do is they call the underlying implementations and wrap the results with AnyView to erase the type.

For more information feel free to take a look at the excellent SwiftUI Custom Styling article at The SwiftUI Lab.

Let's now create a default style for all our modals...

struct DefaultModalStyle: ModalStyle {
    let animation: Animation? = .easeInOut(duration: 0.5)
    
    func makeBackground(configuration: ModalStyle.BackgroundConfiguration, isPresented: Binding<Bool>) -> some View {
        configuration.background
            .edgesIgnoringSafeArea(.all)
            .foregroundColor(.black)
            .opacity(0.3)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .zIndex(1000)
            .onTapGesture {
                isPresented.wrappedValue = false
            }
    }
    
    func makeModal(configuration: ModalStyle.ModalContentConfiguration, isPresented: Binding<Bool>) -> some View {
        configuration.content
            .background(Color.white)
            .clipShape(RoundedRectangle(cornerRadius: 16))
            .zIndex(1001)
    }
}

You can create your own styles just by copying this struct and changing the code within the makeBackground and makeModal functions. This way the modal can look however you want!

Passing the style using environment

Now that we have our style we need to be able to pass it somehow to our Modal View so that it can refer to it using the @Environment property wrapper.

First let's define the custom Environment key and extend the EnvironmentValues. This will allow us to reference this environment key using its key path (as in @Environment(.modalStyle)).

Notice that environment keys need to have default values.

struct ModalStyleKey: EnvironmentKey {
    public static let defaultValue: AnyModalStyle = AnyModalStyle(DefaultModalStyle())
}

extension EnvironmentValues {
    var modalStyle: AnyModalStyle {
        get {
            return self[ModalStyleKey.self]
        }
        set {
            self[ModalStyleKey.self] = newValue
        }
    }
}

Second let's create another View extension so that we can apply our style easily and so that the style can propagate throughout the View hierarchy.

extension View {
    func modalStyle<Style: ModalStyle>(_ style: Style) -> some View {
        self
            .environment(\.modalStyle, AnyModalStyle(style))
    }
}

Notice again the type erasure. Annoying, isn't it?

Using the modal

We're done! The last step is to simply use the Modal Dialog.

Let's first define our main content view.

struct ContentView: View {
    @State var modalIsDisplayed = false
    
    var body: some View {
        VStack {
            Button(action: {
                self.modalIsDisplayed.toggle()
            }) {
                Text("Click me!")
            }
        }
        .modal(isPresented: $modalIsDisplayed) {
            DetailView(isDisplayed: $modalIsDisplayed)
        }
    }
}

Notice how the API mimics the SwiftUI APIs for other presentation styles. Lovely!

Let's also define the Detail View that we're using above.

struct DetailView: View {
    @Binding var isDisplayed: Bool
    
    var body: some View {
        VStack(spacing: 32) {
            Text("Nice modal, eh?")
                .font(.title)
            
            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec tellus pellentesque urna sollicitudin sagittis. Fusce sem justo, eleifend eget dignissim sed, convallis at velit.")
                .fixedSize(horizontal: false, vertical: true)
                .frame(width: 300)
            
            Button(action: {
                self.isDisplayed = false
            }) {
                Text("Dismiss")
            }
        }
        .padding()
    }
    
    init(isDisplayed: Binding<Bool>) {
        self._isDisplayed = isDisplayed
    }
}

The code here is self-explanatory. And you already know what the underscore thing is!

One interesting part may be that we're passing the isDisplayed binding to the Detail View so that it can dismiss itself when needed.

Thank you!

I hope you enjoyed this short article! We learned how to create a custom Modal Dialog with pure SwiftUI and how to style it so that our users can modify its appearance as they want.

Full code

import SwiftUI

/// Modal View

struct ModalView<Parent: View, Content: View>: View {
    @Environment(\.modalStyle) var style: AnyModalStyle
    
    @Binding var isPresented: Bool
    
    var parent: Parent
    var content: Content
    
    let backgroundRectangle = Rectangle()
    
    var body: some View {
        ZStack {
            parent
            
            if isPresented {
                style.makeBackground(
                    configuration: ModalStyleBackgroundConfiguration(
                        background: backgroundRectangle
                    ),
                    isPresented: $isPresented
                )
                style.makeModal(
                    configuration: ModalStyleModalContentConfiguration(
                        content: AnyView(content)
                    ),
                    isPresented: $isPresented
                )
            }
        }
        .animation(style.animation)
    }
    
    init(isPresented: Binding<Bool>, parent: Parent, @ViewBuilder content: () -> Content) {
        self._isPresented = isPresented
        self.parent = parent
        self.content = content()
    }
}

extension View {
    func modal<ModalBody: View>(
            isPresented: Binding<Bool>,
            @ViewBuilder modalBody: () -> ModalBody
    ) -> some View {
        ModalView(
            isPresented: isPresented,
            parent: self,
            content: modalBody
        )
    }
}

/// Modal Style

protocol ModalStyle {
    associatedtype Background: View
    associatedtype Modal: View
    
    var animation: Animation? { get }
    
    func makeBackground(configuration: BackgroundConfiguration, isPresented: Binding<Bool>) -> Background
    func makeModal(configuration: ModalContentConfiguration, isPresented: Binding<Bool>) -> Modal
    
    typealias BackgroundConfiguration = ModalStyleBackgroundConfiguration
    typealias ModalContentConfiguration = ModalStyleModalContentConfiguration
}

extension ModalStyle {
    func anyMakeBackground(configuration: BackgroundConfiguration, isPresented: Binding<Bool>) -> AnyView {
        AnyView(
            makeBackground(configuration: configuration, isPresented: isPresented)
        )
    }
    
    func anyMakeModal(configuration: ModalContentConfiguration, isPresented: Binding<Bool>) -> AnyView {
        AnyView(
            makeModal(configuration: configuration, isPresented: isPresented)
        )
    }
}

public struct AnyModalStyle: ModalStyle {
    let animation: Animation?
    
    private let _makeBackground: (ModalStyle.BackgroundConfiguration, Binding<Bool>) -> AnyView
    private let _makeModal: (ModalStyle.ModalContentConfiguration, Binding<Bool>) -> AnyView
    
    init<Style: ModalStyle>(_ style: Style) {
        self.animation = style.animation
        self._makeBackground = style.anyMakeBackground
        self._makeModal = style.anyMakeModal
    }
    
    func makeBackground(configuration: ModalStyle.BackgroundConfiguration, isPresented: Binding<Bool>) -> AnyView {
        return self._makeBackground(configuration, isPresented)
    }
    
    func makeModal(configuration: ModalStyle.ModalContentConfiguration, isPresented: Binding<Bool>) -> AnyView {
        return self._makeModal(configuration, isPresented)
    }
}

struct ModalStyleKey: EnvironmentKey {
    public static let defaultValue: AnyModalStyle = AnyModalStyle(DefaultModalStyle())
}

extension EnvironmentValues {
    var modalStyle: AnyModalStyle {
        get {
            return self[ModalStyleKey.self]
        }
        set {
            self[ModalStyleKey.self] = newValue
        }
    }
}

extension View {
    func modalStyle<Style: ModalStyle>(_ style: Style) -> some View {
        self
            .environment(\.modalStyle, AnyModalStyle(style))
    }
}

/// Modal Style Configuration

struct ModalStyleBackgroundConfiguration {
    let background: Rectangle
}

struct ModalStyleModalContentConfiguration {
    let content: AnyView
}

/// Default Modal Style

struct DefaultModalStyle: ModalStyle {
    let animation: Animation? = .easeInOut(duration: 0.5)
    
    func makeBackground(configuration: ModalStyle.BackgroundConfiguration, isPresented: Binding<Bool>) -> some View {
        configuration.background
            .edgesIgnoringSafeArea(.all)
            .foregroundColor(.black)
            .opacity(0.3)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .zIndex(1000)
            .onTapGesture {
                isPresented.wrappedValue = false
            }
    }
    
    func makeModal(configuration: ModalStyle.ModalContentConfiguration, isPresented: Binding<Bool>) -> some View {
        configuration.content
            .background(Color.white)
            .clipShape(RoundedRectangle(cornerRadius: 16))
            .zIndex(1001)
    }
}

/// Example

struct ContentView: View {
    @State var modalIsDisplayed = false
    
    var body: some View {
        VStack {
            Button(action: {
                self.modalIsDisplayed.toggle()
            }) {
                Text("Click me!")
            }
        }
        .modal(isPresented: $modalIsDisplayed) {
            DetailView(isDisplayed: $modalIsDisplayed)
        }
    }
}

struct DetailView: View {
    @Binding var isDisplayed: Bool
    
    var body: some View {
        VStack(spacing: 32) {
            Text("Nice modal, eh?")
                .font(.title)
            
            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec tellus pellentesque urna sollicitudin sagittis. Fusce sem justo, eleifend eget dignissim sed, convallis at velit.")
                .fixedSize(horizontal: false, vertical: true)
                .frame(width: 300)
            
            Button(action: {
                self.isDisplayed = false
            }) {
                Text("Dismiss")
            }
        }
        .padding()
    }
    
    init(isDisplayed: Binding<Bool>) {
        self._isDisplayed = isDisplayed
    }
}