MorphModalKit is a lightweight, flexible UIKit package for building card-stack modals with smooth “morph” (replace) animations and support for sticky elements. It provides a blank-canvas container—feel free to use your own views and components.
If you are looking to use SwiftUI you can checkout the SwiftUI docs although this SwiftUI implementation is experimental and for a more stable approach I'd recommend using UIKit for this modal system.
- In Xcode, choose File ▶ Add Packages…
- Enter this URL:
https://github.com/jsmmth/MorphModalKit.git
- Click Add Package, select your target(s), and finish.
If you manage dependencies via a manifest:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v15)],
dependencies: [
.package(url: "https://github.com/jsmmth/MorphModalKit.git", from: "0.0.1"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "MorphModalKit", package: "MorphModalKit")
]
)
]
)
Then in your code:
import MorphModalKit
Conform your UIViewController
to ModalView
:
public protocol ModalView: UIViewController {
/// Desired height for a given container width
func preferredHeight(for width: CGFloat) -> CGFloat
/// Whether the modal can be dismissed by swipe or tapping overlay
var canDismiss: Bool { get }
/// If returning a UIScrollView, its top-bounce gesture will dismiss the modal
var dismissalHandlingScrollView: UIScrollView? { get }
// Optional lifecycle hooks
func modalWillAppear()
func modalDidAppear()
func modalWillDisappear()
func modalDidDisappear()
}
Minimal example:
class ExampleModal: UIViewController, ModalView {
override func viewDidLoad() {
super.viewDidLoad()
// Build your UI…
}
func preferredHeight(for _: CGFloat) -> CGFloat { 320 }
}
Tip: The container will never exceed the device height; it adapts when the keyboard appears. Wrap content in a scroll view and return it in
dismissalHandlingScrollView
to enable pull-to-dismiss from the scroll gesture.
All presentation APIs live on any UIViewController
once you import MorphModalKit.
presentModal(
ExampleModal(),
options: ModalOptions.default,
sticky: StickyOption = .none,
animated: true,
showsOverlay: true
)
sticky
(optional): supply a subclass ofStickyElementsContainer
using.sticky(MySticky.self)
to render persistent UI which sticks around during morph (replace) animations. Allowing you to have a view which acts as a container of shared elements between replace calls.options
: adjust layout, shadows, animation springs, and more (see Configuration below).
Within a ModalView
, you can retrieve the host controller:
modalVC?.push(AnotherModal()) // new modalView added to the stack
modalVC?.pop() // back to previous card
modalVC?.hide() // dismiss the entire stack
When you push a new modal to the stack you can also provide sticky
param (defaults to .none
) to either inherit the previous modals sticky elements, provide a new sticky with .sticky(MySticky.self)
element or set it to .none
.
Swap the content of the top card without moving the card itself:
modalVC?.replace(
with: NextModal(),
direction: .forward, // or .backward
animation: .scale // or .slide(100)
)
.scale
(default): fades between views, scaling from 95–105%..slide(px)
: both incoming and outgoing views slide horizontally bypx
.
Use a StickyElementsContainer
subclass to overlay persistent UI (e.g., headers, footers, navigation bars) across replace animations.
class MySticky: StickyElementsContainer {
required init(modalVC: ModalViewController) {
super.init(modalVC: modalVC)
// add subviews & constraints…
}
required init?(coder: NSCoder) { fatalError() }
override func contextDidChange(
to newOwner: ModalView,
from oldOwner: ModalView?,
animated: Bool
) {
// update state when the modal content changes
}
}
Pass your sticky class to present(…, sticky:)
or push(…, sticky:)
.
Tip: See Examples/UIKitExample/Modals/StickyElements.swift for an example of how I have used this in the example.
Note: Interaction events “fall through” the container except on its interactive subviews.
Customize every aspect via ModalOptions
:
Property | What it does | Default |
---|---|---|
horizontalInset |
Side margins of each card | 10 |
bottomSpacing |
Space from bottom (nil = safe area + 10) | nil |
stackVerticalSpacing |
Gap between stacked cards | 20 |
keyboardSpacing |
Gap between the bottom of the card and the keyboard | 10 |
cornerRadius |
Card corner radius | 32 |
cornerMask |
Card corner mask | [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] |
maxVisibleStack |
How many cards peek behind the front card | 2 |
dimBackgroundColor |
Color of background cards | .black |
dimOpacityMultiplier |
Darkness of background cards | 0.06 |
overlayColor |
Overlay backdrop color | .black |
overlayOpacity |
Overlay backdrop opacity | 0.2 |
modalBackgroundColor |
Base card background | .secondarySystemGroupedBackground |
animation |
Spring settings for present/push/pop | (0.4, damping:0.86, velocity:0.8) |
morphAnimation |
Spring settings for replace (morph) | (0.4, damping:0.95, velocity:1) |
cardShadow |
Card shadow (color, opacity, radius, offset) |
(.black, 0.12, 9, (0,2)) |
usesSnapshots |
Snapshot offscreen cards for performance | true |
usesSnapshotsForMorph |
Snapshot during morph replacements | false |
showsHandle |
Show drag-handle when dismissable | true |
handleColor |
Color of the drag-handle | .tertiarySystemGroupedBackground |
centerOnIpad |
Whether ot not the modal should be centered on iPad or not | true |
centerIPadWidthMultiplier |
Width of the modal when centered on iPad | 0.7 |
Example: Full-width, bottom-pinned modal
var opts = ModalOptions.default
opts.horizontalInset = 0
opts.bottomSpacing = 0
opts.cornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
presentModal(MyModal(), options: opts, sticky: MySticky.self)
- Performance: Background cards snapshot themselves for smooth animations. Disable via
options.usesSnapshots = false
. - Keyboard Avoidance: The stack re-layouts on keyboard frame changes, maintaining
keyboardSpacing
. - Scroll-to-Dismiss: Return your scroll view in
dismissalHandlingScrollView
, helpful when wrapping content in aUIScrollView
or subclass. - Disable Dismissal: Override
var canDismiss: Bool { false }
in yourModalView
You'll still be able to programmatically call.pop
'.
See Examples/UIKitExample/Modals for a working demo using only the package’s APIs. Feel free to swap in your own UI—this example is just a starting point.
Happy morphing! 🚀