Skip to content

Repository files navigation

MorphModalKit

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.

MorphModalKit


🚀 Installation

Swift Package Manager

  1. In Xcode, choose File ▶ Add Packages…
  2. Enter this URL:
    https://github.com/jsmmth/MorphModalKit.git
    
  3. Click Add Package, select your target(s), and finish.

Package.swift

If you manage dependencies via a manifest:

// swift-tools-version:5.9
import PackageDescription
letpackage=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

🎨 Core Concepts

ModalView

Conform your UIViewController to ModalView:

publicprotocolModalView: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
varcanDismiss:Bool{get}
/// If returning a UIScrollView, its top-bounce gesture will dismiss the modal
vardismissalHandlingScrollView:UIScrollView?{get}
// Optional lifecycle hooks
func modalWillAppear(fromReplaced:Bool)func modalDidAppear(fromReplaced:Bool)func modalWillDisappear(beingReplaced:Bool)func modalDidDisappear(beingReplaced:Bool)}

Minimal example:

classExampleModal:UIViewController,ModalView{overridefunc 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.


📱 Presenting & Navigation

All presentation APIs live on any UIViewController once you import MorphModalKit.

Presenting

presentModal(ExampleModal(),
options:ModalOptions.default,
sticky: StickyOption =.none,
animated:true,
showsOverlay:true)
  • sticky (optional): supply a subclass of StickyElementsContainer 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).

Pushing & Popping

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.

Replace (Morph)

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 by px.

📌 StickyElementsContainer

Use a StickyElementsContainer subclass to overlay persistent UI (e.g., headers, footers, navigation bars) across replace animations.

classMySticky:StickyElementsContainer{requiredinit(modalVC:ModalViewController){
super.init(modalVC: modalVC)
// add subviews & constraints…
}requiredinit?(coder:NSCoder){fatalError()}overridefunc 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.


⚙️ Configuration

Customize every aspect via ModalOptions:

PropertyWhat it doesDefault
horizontalInsetSide margins of each card10
bottomSpacingSpace from bottom (nil = safe area + 10)nil
stackVerticalSpacingGap between stacked cards20
keyboardSpacingGap between the bottom of the card and the keyboard10
handlebarWidthWidth of the handlebar52
handlebarHeightHeight of the handlebar4
keyboardSpacingGap between the bottom of the card and the keyboard10
cornerRadiusCard corner radius32
innerCornerRadiusCard corner radius for inner wrapper (useful for top radius being unique)nil
innerCornerMaskCard corner mask for inner wrappernil
cornerMaskCard corner mask[.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
maxVisibleStackHow many cards peek behind the front card2
dimBackgroundColorColor of background cards.black
dimOpacityMultiplierDarkness of background cards0.06
overlayColorOverlay backdrop color.black
overlayOpacityOverlay backdrop opacity0.2
modalBackgroundColorBase card background.secondarySystemGroupedBackground
animationSpring settings for present/push/pop(0.4, damping:0.86, velocity:0.8)
morphAnimationSpring settings for replace (morph)(0.4, damping:0.95, velocity:1)
cardShadowCard shadow (color, opacity, radius, offset)(.black, 0.12, 9, (0,2))
usesSnapshotsSnapshot offscreen cards for performancetrue
usesSnapshotsForMorphSnapshot during morph replacementsfalse
showsHandleShow drag-handle when dismissabletrue
handleColorColor of the drag-handle.tertiarySystemGroupedBackground
enableGlassEnables liquid glass background effect (iOS 26 only)false
glassStyleWhen enableGlass is true allows you to change glass style (iOS 26 only).regular
centerOnIpadWhether ot not the modal should be centered on iPad or nottrue
centerIPadWidthMultiplierWidth of the modal when centered on iPad0.7

Example: Full-width, bottom-pinned modal

varopts=ModalOptions.default
opts.horizontalInset =0
opts.bottomSpacing =0
opts.cornerMask =[.layerMinXMinYCorner,.layerMaxXMinYCorner]presentModal(MyModal(), options: opts, sticky:MySticky.self)

🔍 Tips & Tricks

  • 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 a UIScrollView or subclass.
  • Disable Dismissal: Override var canDismiss: Bool { false } in your ModalView You'll still be able to programmatically call .pop'.

🛠 Example Project

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! 🚀

About

Simple Morphable, Stackable Modals

Resources

Stars

251 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages