An iOS image cropping library for UIKit and SwiftUI, mimicking the Photos app — crop shapes, rotation, flip, perspective correction, undo/redo. iOS & Mac Catalyst.
Swift
1,208
795 commits
updated Sep 9, 2026
Mantis is an open-source iOS image cropping library written in Swift, with both UIKit and SwiftUI APIs. It gives your app an Apple Photos–style photo crop and edit experience: crop with rotation, flip, free or fixed aspect ratios, rich crop shapes (circle, ellipse, rounded rectangle, polygon, heart, arbitrary paths), perspective correction (skew), and full undo/redo — on iOS and Mac Catalyst.
ImageCropper view with modifiers and an observable CropSession, plus a binding-based ImageCropperViewCropInfo is Codable (3.1+); re-crop the original image offline in a later session, or reopen the editor at the saved stateCropViewController, tweak colors, borders, dial styles, and morePrivacyInfo.xcprivacy)
Mantis provides rich crop shapes, from basic circle/square to polygons to arbitrary paths (we even provide a heart shape ❤️ 😏).
import Mantis
import SwiftUI
struct AvatarCropView: View {
let image: UIImage
@State private var croppedImage: UIImage?
var body: some View {
ImageCropper(image: image)
.cropShape(.circle)
.aspectRatio(.fixed(1))
.onCrop { result in
croppedImage = result.croppedImage
}
}
}
import Mantis
let cropViewController = Mantis.cropViewController(image: yourImage)
cropViewController.delegate = self
cropViewController.modalPresentationStyle = .fullScreen // required when presenting
present(cropViewController, animated: true)
// Receive the result:
extension YourViewController: CropViewControllerDelegate {
func cropViewControllerDidCrop(_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo) {
// Use the cropped image
}
func cropViewControllerDidCancel(_ cropViewController: CropViewController,
original: UIImage) {
// Handle cancel
}
}
See Usage below for aspect ratios, crop shapes, undo/redo, persistence, and full customization.
CropSession)Use the lightweight Mantis-spm distribution. It contains only the sources and is under 1 MB, whereas this repository's full history (example apps, README media) is about 70 MB and SwiftPM downloads all of it for every project that depends on it.
In Xcode: File → Add Package Dependencies… and enter:
https://github.com/guoyingtao/Mantis-spm.git
Rule: Version — Up to Next Major — 3.1.0
Or in Package.swift:
.package(url: "https://github.com/guoyingtao/Mantis-spm.git", from: "3.1.0")
and in your target's dependencies: .product(name: "Mantis", package: "Mantis-spm"). The product and module are unchanged, so import Mantis works as before.
Depending on https://github.com/guoyingtao/Mantis.git directly still works, but downloads far more. Don't use both URLs in the same project: SwiftPM derives the package identity from the URL (mantis-spm vs mantis) and would treat them as two packages exporting the same product.
pod 'Mantis', '~> 3.1.0'
github "guoyingtao/Mantis"
For the complete history see the CHANGELOG.
Mantis 3.0 introduced a modern, declarative SwiftUI API:
ImageCropper — a SwiftUI view configured with modifiers:ImageCropper(image: myImage)
.cropShape(.circle)
.aspectRatio(.fixed(16/9))
.onCrop { result in croppedImage = result.croppedImage }
CropSession — an observable handle to a live crop session. It exposes canUndo, canRedo, isResettable and the live transformation as observable state, and drives the cropper with plain methods (rotate(), flip(), crop(), undo(), redo(), reset(), setAspectRatio()) instead of the old enum-binding action pattern. On iOS 17+ it participates in the Observation framework for fine-grained, per-property updates; on iOS 15/16 it behaves as a plain ObservableObject.The binding-based ImageCropperView API remains fully supported. See the SwiftUI usage section for the full API and the MantisSwiftUIExample project for working demos.
CropInfo now conforms to Codable, so you can save a user's exact crop and reproduce it offline in a later app session — including rotated, fixed-ratio, perspective-skewed, and large-image crops. See Persisting and restoring crops.
Mantis supports Apple Photos–style perspective correction, letting users adjust horizontal and vertical skew in addition to straightening. When enabled, the slide dial displays three circular icon buttons — Straighten, Vertical, and Horizontal — so users can switch adjustment modes with a single tap.
CATransform3DCIPerspectiveCorrectionMantis supports light, dark, and system appearance modes. By default Mantis uses a dark appearance (backward compatible).
var config = Mantis.Config()
config.appearanceMode = .forceLight // or .forceDark (default), .system
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
Important: set
modalPresentationStyle = .fullScreenon the crop view controller (or its navigation controller) when presenting it.
let cropViewController = Mantis.cropViewController(image: yourImage)
cropViewController.delegate = self
cropViewController.modalPresentationStyle = .fullScreen
present(cropViewController, animated: true)
The caller conforms to CropViewControllerDelegate:
public protocol CropViewControllerDelegate: AnyObject {
func cropViewControllerDidCrop(_ cropViewController: CropViewController, cropped: UIImage, transformation: Transformation, cropInfo: CropInfo)
func cropViewControllerDidCancel(_ cropViewController: CropViewController, original: UIImage)
// The implementation of the following functions are optional
func cropViewControllerDidFailToCrop(_ cropViewController: CropViewController, original: UIImage)
func cropViewControllerDidBeginResize(_ cropViewController: CropViewController)
func cropViewControllerDidEndResize(_ cropViewController: CropViewController, original: UIImage, cropInfo: CropInfo)
}
ImageCropper is a declarative SwiftUI view configured with modifiers:
struct MyView: View {
@State private var croppedImage: UIImage?
var body: some View {
ImageCropper(image: myImage)
.cropShape(.circle)
.aspectRatio(.fixed(16/9))
.onCrop { result in
croppedImage = result.croppedImage
}
}
}
To build your own controls, attach a CropSession. It exposes canUndo, canRedo,
isResettable and the live transformation as observable state (fine-grained
Observation tracking on iOS 17+, ObservableObject on iOS 15/16), and drives the
cropper with plain methods instead of enum bindings:
struct MyCropperView: View {
@StateObject private var session = CropSession()
@State private var croppedImage: UIImage?
var body: some View {
VStack {
ImageCropper(image: myImage, session: session)
.builtInToolbarVisible(false)
.onCrop { result in croppedImage = result.croppedImage }
HStack {
Button("Undo") { session.undo() }
.disabled(!session.canUndo)
Button("Redo") { session.redo() }
.disabled(!session.canRedo)
Button("Reset") { session.reset() }
.disabled(!session.isResettable)
Button("Rotate") { session.rotate(.clockwise) }
Button("Flip") { session.flip(.horizontal) }
Button("Done") { session.crop() }
}
}
}
}
Available modifiers: .cropShape(_:), .aspectRatio(_:), .builtInToolbarVisible(_:),
.appearance(_:), .configure { $0... } (escape hatch to the full Mantis.Config),
.onCrop(_:), .onCancel(_:), .onCropFailed(_:).
Kept for backward compatibility and fully supported; new code should prefer the declarative ImageCropper above.
struct MyView: View {
@State private var image: UIImage?
@State private var transformation: Transformation?
@State private var cropInfo: CropInfo?
var body: some View {
ImageCropperView(
image: $image,
transformation: $transformation,
cropInfo: $cropInfo
)
}
}
Note:
- To start a crop operation programmatically, use the
actionbinding (forImageCropperView):action = .crop- To receive the result of the crop (success or failure), use the
onCropCompletedcallback.
This is especially useful because cropping may not complete instantly in all cases, so relying on this callback ensures you update your UI only after the operation finishes.
CropToolbar has two modes:
normal mode
In normal mode, you can use a set of standard CropViewController photo editing features with "Cancel" and "Done" buttons.
let cropViewController = Mantis.cropViewController(image: yourImage)
embedded mode
This mode does not include "Cancel" and "Done" buttons, so you can embed CropViewController into another view controller and build your own surrounding UI.
var config = Mantis.Config()
config.cropToolbarConfig.mode = .embedded
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
// Add a custom ratio 1:2 for portrait orientation
var config = Mantis.Config()
config.addCustomRatio(byVerticalWidth: 1, andVerticalHeight: 2)
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
// Set the ratioOptions of the config if you don't want to keep all default ratios
var config = Mantis.Config()
//config.ratioOptions = [.original, .square, .custom]
config.ratioOptions = [.custom]
config.addCustomRatio(byVerticalWidth: 1, andVerticalHeight: 2)
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
presetFixedRatioType to alwaysUsingOnePresetFixedRatio:config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 16.0 / 9.0)
When choosing alwaysUsingOnePresetFixedRatio, the fixed-ratio setting button does not show. (In the declarative SwiftUI API this is .aspectRatio(.fixed(16/9)).)
config.cropViewConfig.showAttachedRotationControlView = falseconfig.cropToolbarConfig.ratioCandidatesShowType = .alwaysShowRatioListpublic enum RatioCandidatesShowType {
case presentRatioList
case alwaysShowRatioList
}
// set a custom fixed ratio
cropToolbarDelegate?.didSelectRatio(ratio: 9 / 16)
To use a different crop shape — for example a circular avatar crop — set config.cropViewConfig.cropShapeType:
public enum CropShapeType {
case rect
case square
case ellipse(maskOnly: Bool = false)
case circle(maskOnly: Bool = false)
case roundedRect(radiusToShortSide: CGFloat, maskOnly: Bool = false)
case diamond(maskOnly: Bool = false)
case heart(maskOnly: Bool = false)
case polygon(sides: Int, offset: CGFloat = 0, maskOnly: Bool = false)
case path(points: [CGPoint], maskOnly: Bool = false)
}
With maskOnly: true, the shape is used only as a visual mask and the exported image stays rectangular; with the default maskOnly: false, the exported image is cut to the shape with a transparent background.
In the declarative SwiftUI API the same type is used: .cropShape(.circle) (the cases whose associated values all have defaults can be written without parentheses).
If you want to apply transformations when showing an image (for example to reopen the editor at a previously saved state), set config.cropViewConfig.presetTransformationType:
public enum PresetTransformationType {
case none
case presetInfo(info: Transformation)
case presetNormalizedInfo(normalizedInfo: CGRect)
}
Please use the transformation information obtained previously from the delegate method cropViewControllerDidCrop(_:cropped:transformation:cropInfo:), or — in the declarative SwiftUI API — from CropResult.transformation delivered by .onCrop.
Codable) 🆕As of Mantis 3.1.0, CropInfo conforms to Codable, so you can save a user's exact crop and reproduce it offline in a later session — including rotated, fixed-ratio, perspective-skewed, and large-image crops.
// Save the CropInfo delivered by the delegate (or CropResult.cropInfo in SwiftUI).
func cropViewControllerDidCrop(_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo) {
let data = try? JSONEncoder().encode(cropInfo)
// ...persist `data` (UserDefaults, a file, a database, etc.)
}
// Later — even in a new app session — re-crop the original image offline, no UI:
let cropInfo = try JSONDecoder().decode(CropInfo.self, from: data)
let recropped = Mantis.crop(image: originalImage, by: cropInfo)
CropInfo crops identically to the same-session value: the opaque view-reconstruction state needed by the perspective-skew and maxImagePixelCount (large-image) paths is encoded alongside the public fields.CropInfo that Mantis produced (from the delegate or getCropInfo()). A CropInfo you build by hand via the public init carries no view-reconstruction state, so the perspective and large-image paths still return nil for it — exactly as before.Transformation and restoring it via presetTransformationType = .presetInfo(info:) remains the lighter option.Compatibility: adding
Codableis purely additive — existing code needs no changes. The one exception: if you previously wrote your ownextension CropInfo: Codableas a workaround, remove it after upgrading to avoid a duplicate-conformance error. The encoded JSON mirrorsCropInfo's fields, so treat versioning of your own persisted data as your app's responsibility.
Mantis offers full support for Undo, Redo, and Revert to Original on both iOS and Mac Catalyst.
To enable this feature, set config.enableUndoRedo = true.
In the declarative SwiftUI API, attaching a CropSession enables undo/redo automatically and exposes canUndo / canRedo / isResettable as observable state.
Each crop view controller keeps its own undo history, so multiple croppers can be shown at the same time (Mantis 3.0+).
Catalyst menus for this feature are localized.
Enable perspective correction to let users adjust horizontal and vertical skew, similar to the Apple Photos app.
var config = Mantis.Config()
config.cropViewConfig.enablePerspectiveCorrection = true
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
When enablePerspectiveCorrection is true, the slide dial is used by default (no need to set builtInRotationControlViewType explicitly) and automatically switches to withTypeSelector mode, showing three circular icon buttons (Straighten / Vertical / Horizontal) above the ruler. Users can tap each button to switch adjustment modes.
The skew values are included in the Transformation and CropInfo returned by the delegate, so you can persist and restore them via presetTransformationType.
You can optionally customize the appearance of the type selector buttons through SlideDialConfig:
typeButtonSize — diameter of each circular button (default: 48)typeButtonSpacing — spacing between buttons (default: 16)activeColor — color for the selected button ring and value textinactiveColor — color for unselected buttonspointerColor — color of the center pointer on the rulerskewLimitation — maximum skew angle in degrees (default: 30)Set the appearance mode to control the overall look of the crop UI:
var config = Mantis.Config()
config.appearanceMode = .forceLight // or .forceDark (default), .system
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
public enum AppearanceMode {
/// Always use dark appearance (default, backward compatible)
case forceDark
/// Always use light appearance
case forceLight
/// Follow system light/dark mode setting
case system
}
.forceDark is the default, keeping the existing dark-themed behavior..forceLight uses a light color scheme similar to Apple Photos in light mode..system dynamically adapts to the user's system-wide light/dark mode setting.The appearance mode affects all UI components including the toolbar, dimming overlay, rotation dial, type selector, and ratio item views.
Mantis ships with built-in localizations for Arabic, Chinese (Simplified and Traditional), Dutch, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish, and Turkish.
fig 1
SwiftUI project: please check this discussion.
Static frameworks: if you use static frameworks in CocoaPods, you need to add the code below in order to find the correct resource bundle.
Mantis.locateResourceBundle(by: Self.self)
By default Mantis uses its built-in localization tables, and not every language is supported out of the box (see fig 1).
If your app supports languages that are not built in, you can define your own strings table, localize it in your application target or framework, and point Mantis at it.
Important: first create a strings file with these keys:
"Mantis.Done" = "";
"Mantis.Cancel" = "";
"Mantis.Reset" = "";
"Mantis.Original" = "";
"Mantis.Square" = "";
"Mantis.Straighten" = "";
"Mantis.Horizontal" = "";
"Mantis.Vertical" = "";
Then configure Mantis:
let config = Mantis.Config()
config.localizationConfig.bundle = // a bundle where the strings file is located
config.localizationConfig.tableName = // the localized strings file name within the bundle
If needed you can subclass CropViewController:
class CustomViewController: CropViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do your custom logic here.
// The MantisExample project also has a showcase for a CustomViewController.
}
}
To get an instance, Mantis provides a factory method:
let cropViewController: CustomViewController = Mantis.cropViewController(image: image, config: config)
Mantis 3.0 contains a small set of breaking changes; most apps only need the renames below. Everything else — including the binding-based SwiftUI ImageCropperView — is source compatible.
CropViewConfig propertiesThese four properties had been deprecated forwarding shims; use the replacements:
| Removed in 3.0 | Use instead |
|---|---|
cropViewConfig.cropBoxHotAreaUnit | cropViewConfig.cropAuxiliaryIndicatorConfig.cropBoxHotAreaUnit |
cropViewConfig.disableCropBoxDeformation | cropViewConfig.cropAuxiliaryIndicatorConfig.disableCropBoxDeformation |
cropViewConfig.cropAuxiliaryIndicatorStyle | cropViewConfig.cropAuxiliaryIndicatorConfig.style |
cropViewConfig.showRotationDial | cropViewConfig.showAttachedRotationControlView |
CropInfoFive view-reconstruction fields (skewSublayerTransform, scrollContentOffset, scrollBoundsSize, imageContainerFrame, scrollViewTransform) moved out of the public struct and its init; they were pure view-hierarchy plumbing for the perspective and large-image crop paths.
CropInfo back into Mantis.crop(image:by:) (from the delegate or getCropInfo()): no change, including across value copies.CropInfo from persisted semantic fields via the public init for offline re-cropping: no change for the standard path. The perspective-skew and maxImagePixelCount overflow paths return nil for a rebuilt CropInfo — the same outcome the previous zero-value guards produced. As of 3.1, prefer persisting the Codable CropInfo Mantis produced — see Persisting and restoring crops.Transformation and feed it back via presetTransformationType = .presetInfo(info:) — that path is unchanged.Mantis.cropViewController(image:config:) factory now correctly wires up undo/redo when config.enableUndoRedo = true; previously only the setupCropViewController path did.Mantis provides two demo projects:
ImageCropper + CropSession API (Mantis 3.0+): normal crop with restore, crop shapes, fixed ratio, slide dial, perspective correction, and a custom toolbar driven by session state.ImageCropperView, kept as a 2.x migration reference.Set modalPresentationStyle = .fullScreen on the crop view controller (or its navigation controller) before presenting it. On iOS 13+ the default sheet presentation style does not work with the crop UI.
Use config.cropViewConfig.cropShapeType = .circle() (UIKit) or .cropShape(.circle) (SwiftUI). The exported UIImage is cut to the circle with a transparent background. If you want the exported image to stay rectangular and only show a circular mask in the UI, use .circle(maskOnly: true).
Two options:
Codable CropInfo (Mantis 3.1+) and pass it to Mantis.crop(image:by:) later.Transformation and restore it via config.cropViewConfig.presetTransformationType = .presetInfo(info:).Yes — Mantis supports iOS and Mac Catalyst, including localized Catalyst menus for undo/redo and independent undo histories for multiple croppers (e.g. iPad multi-window).
Set config.cropMode = .async to crop off the main thread, and/or set config.cropViewConfig.maxImagePixelCount to cap the output pixel count for very large photos.
Below are apps that use the Mantis framework. If your app also uses Mantis and you'd like it showcased here, please submit a PR following the existing format.
Pictopoem Where Images Whisper Poems | Text Behind Me Add Depth to Your Photos |
|---|
If Mantis saves you time, consider becoming a sponsor through GitHub Sponsors.
Mantis is released under the MIT License.
142 followers · starred Jul 2021
240 followers · starred Feb 2025
444 followers · starred Feb 2020
14 followers · starred Oct 2024
An iOS image cropping library for UIKit and SwiftUI, mimicking the Photos app — crop shapes, rotation, flip, perspective correction, undo/redo. iOS & Mac Catalyst.
Swift
1,208
795 commits
updated Sep 9, 2026
Mantis is an open-source iOS image cropping library written in Swift, with both UIKit and SwiftUI APIs. It gives your app an Apple Photos–style photo crop and edit experience: crop with rotation, flip, free or fixed aspect ratios, rich crop shapes (circle, ellipse, rounded rectangle, polygon, heart, arbitrary paths), perspective correction (skew), and full undo/redo — on iOS and Mac Catalyst.
ImageCropper view with modifiers and an observable CropSession, plus a binding-based ImageCropperViewCropInfo is Codable (3.1+); re-crop the original image offline in a later session, or reopen the editor at the saved stateCropViewController, tweak colors, borders, dial styles, and morePrivacyInfo.xcprivacy)
Mantis provides rich crop shapes, from basic circle/square to polygons to arbitrary paths (we even provide a heart shape ❤️ 😏).
import Mantis
import SwiftUI
struct AvatarCropView: View {
let image: UIImage
@State private var croppedImage: UIImage?
var body: some View {
ImageCropper(image: image)
.cropShape(.circle)
.aspectRatio(.fixed(1))
.onCrop { result in
croppedImage = result.croppedImage
}
}
}
import Mantis
let cropViewController = Mantis.cropViewController(image: yourImage)
cropViewController.delegate = self
cropViewController.modalPresentationStyle = .fullScreen // required when presenting
present(cropViewController, animated: true)
// Receive the result:
extension YourViewController: CropViewControllerDelegate {
func cropViewControllerDidCrop(_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo) {
// Use the cropped image
}
func cropViewControllerDidCancel(_ cropViewController: CropViewController,
original: UIImage) {
// Handle cancel
}
}
See Usage below for aspect ratios, crop shapes, undo/redo, persistence, and full customization.
CropSession)Use the lightweight Mantis-spm distribution. It contains only the sources and is under 1 MB, whereas this repository's full history (example apps, README media) is about 70 MB and SwiftPM downloads all of it for every project that depends on it.
In Xcode: File → Add Package Dependencies… and enter:
https://github.com/guoyingtao/Mantis-spm.git
Rule: Version — Up to Next Major — 3.1.0
Or in Package.swift:
.package(url: "https://github.com/guoyingtao/Mantis-spm.git", from: "3.1.0")
and in your target's dependencies: .product(name: "Mantis", package: "Mantis-spm"). The product and module are unchanged, so import Mantis works as before.
Depending on https://github.com/guoyingtao/Mantis.git directly still works, but downloads far more. Don't use both URLs in the same project: SwiftPM derives the package identity from the URL (mantis-spm vs mantis) and would treat them as two packages exporting the same product.
pod 'Mantis', '~> 3.1.0'
github "guoyingtao/Mantis"
For the complete history see the CHANGELOG.
Mantis 3.0 introduced a modern, declarative SwiftUI API:
ImageCropper — a SwiftUI view configured with modifiers:ImageCropper(image: myImage)
.cropShape(.circle)
.aspectRatio(.fixed(16/9))
.onCrop { result in croppedImage = result.croppedImage }
CropSession — an observable handle to a live crop session. It exposes canUndo, canRedo, isResettable and the live transformation as observable state, and drives the cropper with plain methods (rotate(), flip(), crop(), undo(), redo(), reset(), setAspectRatio()) instead of the old enum-binding action pattern. On iOS 17+ it participates in the Observation framework for fine-grained, per-property updates; on iOS 15/16 it behaves as a plain ObservableObject.The binding-based ImageCropperView API remains fully supported. See the SwiftUI usage section for the full API and the MantisSwiftUIExample project for working demos.
CropInfo now conforms to Codable, so you can save a user's exact crop and reproduce it offline in a later app session — including rotated, fixed-ratio, perspective-skewed, and large-image crops. See Persisting and restoring crops.
Mantis supports Apple Photos–style perspective correction, letting users adjust horizontal and vertical skew in addition to straightening. When enabled, the slide dial displays three circular icon buttons — Straighten, Vertical, and Horizontal — so users can switch adjustment modes with a single tap.
CATransform3DCIPerspectiveCorrectionMantis supports light, dark, and system appearance modes. By default Mantis uses a dark appearance (backward compatible).
var config = Mantis.Config()
config.appearanceMode = .forceLight // or .forceDark (default), .system
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
Important: set
modalPresentationStyle = .fullScreenon the crop view controller (or its navigation controller) when presenting it.
let cropViewController = Mantis.cropViewController(image: yourImage)
cropViewController.delegate = self
cropViewController.modalPresentationStyle = .fullScreen
present(cropViewController, animated: true)
The caller conforms to CropViewControllerDelegate:
public protocol CropViewControllerDelegate: AnyObject {
func cropViewControllerDidCrop(_ cropViewController: CropViewController, cropped: UIImage, transformation: Transformation, cropInfo: CropInfo)
func cropViewControllerDidCancel(_ cropViewController: CropViewController, original: UIImage)
// The implementation of the following functions are optional
func cropViewControllerDidFailToCrop(_ cropViewController: CropViewController, original: UIImage)
func cropViewControllerDidBeginResize(_ cropViewController: CropViewController)
func cropViewControllerDidEndResize(_ cropViewController: CropViewController, original: UIImage, cropInfo: CropInfo)
}
ImageCropper is a declarative SwiftUI view configured with modifiers:
struct MyView: View {
@State private var croppedImage: UIImage?
var body: some View {
ImageCropper(image: myImage)
.cropShape(.circle)
.aspectRatio(.fixed(16/9))
.onCrop { result in
croppedImage = result.croppedImage
}
}
}
To build your own controls, attach a CropSession. It exposes canUndo, canRedo,
isResettable and the live transformation as observable state (fine-grained
Observation tracking on iOS 17+, ObservableObject on iOS 15/16), and drives the
cropper with plain methods instead of enum bindings:
struct MyCropperView: View {
@StateObject private var session = CropSession()
@State private var croppedImage: UIImage?
var body: some View {
VStack {
ImageCropper(image: myImage, session: session)
.builtInToolbarVisible(false)
.onCrop { result in croppedImage = result.croppedImage }
HStack {
Button("Undo") { session.undo() }
.disabled(!session.canUndo)
Button("Redo") { session.redo() }
.disabled(!session.canRedo)
Button("Reset") { session.reset() }
.disabled(!session.isResettable)
Button("Rotate") { session.rotate(.clockwise) }
Button("Flip") { session.flip(.horizontal) }
Button("Done") { session.crop() }
}
}
}
}
Available modifiers: .cropShape(_:), .aspectRatio(_:), .builtInToolbarVisible(_:),
.appearance(_:), .configure { $0... } (escape hatch to the full Mantis.Config),
.onCrop(_:), .onCancel(_:), .onCropFailed(_:).
Kept for backward compatibility and fully supported; new code should prefer the declarative ImageCropper above.
struct MyView: View {
@State private var image: UIImage?
@State private var transformation: Transformation?
@State private var cropInfo: CropInfo?
var body: some View {
ImageCropperView(
image: $image,
transformation: $transformation,
cropInfo: $cropInfo
)
}
}
Note:
- To start a crop operation programmatically, use the
actionbinding (forImageCropperView):action = .crop- To receive the result of the crop (success or failure), use the
onCropCompletedcallback.
This is especially useful because cropping may not complete instantly in all cases, so relying on this callback ensures you update your UI only after the operation finishes.
CropToolbar has two modes:
normal mode
In normal mode, you can use a set of standard CropViewController photo editing features with "Cancel" and "Done" buttons.
let cropViewController = Mantis.cropViewController(image: yourImage)
embedded mode
This mode does not include "Cancel" and "Done" buttons, so you can embed CropViewController into another view controller and build your own surrounding UI.
var config = Mantis.Config()
config.cropToolbarConfig.mode = .embedded
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
// Add a custom ratio 1:2 for portrait orientation
var config = Mantis.Config()
config.addCustomRatio(byVerticalWidth: 1, andVerticalHeight: 2)
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
// Set the ratioOptions of the config if you don't want to keep all default ratios
var config = Mantis.Config()
//config.ratioOptions = [.original, .square, .custom]
config.ratioOptions = [.custom]
config.addCustomRatio(byVerticalWidth: 1, andVerticalHeight: 2)
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
presetFixedRatioType to alwaysUsingOnePresetFixedRatio:config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 16.0 / 9.0)
When choosing alwaysUsingOnePresetFixedRatio, the fixed-ratio setting button does not show. (In the declarative SwiftUI API this is .aspectRatio(.fixed(16/9)).)
config.cropViewConfig.showAttachedRotationControlView = falseconfig.cropToolbarConfig.ratioCandidatesShowType = .alwaysShowRatioListpublic enum RatioCandidatesShowType {
case presentRatioList
case alwaysShowRatioList
}
// set a custom fixed ratio
cropToolbarDelegate?.didSelectRatio(ratio: 9 / 16)
To use a different crop shape — for example a circular avatar crop — set config.cropViewConfig.cropShapeType:
public enum CropShapeType {
case rect
case square
case ellipse(maskOnly: Bool = false)
case circle(maskOnly: Bool = false)
case roundedRect(radiusToShortSide: CGFloat, maskOnly: Bool = false)
case diamond(maskOnly: Bool = false)
case heart(maskOnly: Bool = false)
case polygon(sides: Int, offset: CGFloat = 0, maskOnly: Bool = false)
case path(points: [CGPoint], maskOnly: Bool = false)
}
With maskOnly: true, the shape is used only as a visual mask and the exported image stays rectangular; with the default maskOnly: false, the exported image is cut to the shape with a transparent background.
In the declarative SwiftUI API the same type is used: .cropShape(.circle) (the cases whose associated values all have defaults can be written without parentheses).
If you want to apply transformations when showing an image (for example to reopen the editor at a previously saved state), set config.cropViewConfig.presetTransformationType:
public enum PresetTransformationType {
case none
case presetInfo(info: Transformation)
case presetNormalizedInfo(normalizedInfo: CGRect)
}
Please use the transformation information obtained previously from the delegate method cropViewControllerDidCrop(_:cropped:transformation:cropInfo:), or — in the declarative SwiftUI API — from CropResult.transformation delivered by .onCrop.
Codable) 🆕As of Mantis 3.1.0, CropInfo conforms to Codable, so you can save a user's exact crop and reproduce it offline in a later session — including rotated, fixed-ratio, perspective-skewed, and large-image crops.
// Save the CropInfo delivered by the delegate (or CropResult.cropInfo in SwiftUI).
func cropViewControllerDidCrop(_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo) {
let data = try? JSONEncoder().encode(cropInfo)
// ...persist `data` (UserDefaults, a file, a database, etc.)
}
// Later — even in a new app session — re-crop the original image offline, no UI:
let cropInfo = try JSONDecoder().decode(CropInfo.self, from: data)
let recropped = Mantis.crop(image: originalImage, by: cropInfo)
CropInfo crops identically to the same-session value: the opaque view-reconstruction state needed by the perspective-skew and maxImagePixelCount (large-image) paths is encoded alongside the public fields.CropInfo that Mantis produced (from the delegate or getCropInfo()). A CropInfo you build by hand via the public init carries no view-reconstruction state, so the perspective and large-image paths still return nil for it — exactly as before.Transformation and restoring it via presetTransformationType = .presetInfo(info:) remains the lighter option.Compatibility: adding
Codableis purely additive — existing code needs no changes. The one exception: if you previously wrote your ownextension CropInfo: Codableas a workaround, remove it after upgrading to avoid a duplicate-conformance error. The encoded JSON mirrorsCropInfo's fields, so treat versioning of your own persisted data as your app's responsibility.
Mantis offers full support for Undo, Redo, and Revert to Original on both iOS and Mac Catalyst.
To enable this feature, set config.enableUndoRedo = true.
In the declarative SwiftUI API, attaching a CropSession enables undo/redo automatically and exposes canUndo / canRedo / isResettable as observable state.
Each crop view controller keeps its own undo history, so multiple croppers can be shown at the same time (Mantis 3.0+).
Catalyst menus for this feature are localized.
Enable perspective correction to let users adjust horizontal and vertical skew, similar to the Apple Photos app.
var config = Mantis.Config()
config.cropViewConfig.enablePerspectiveCorrection = true
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
When enablePerspectiveCorrection is true, the slide dial is used by default (no need to set builtInRotationControlViewType explicitly) and automatically switches to withTypeSelector mode, showing three circular icon buttons (Straighten / Vertical / Horizontal) above the ruler. Users can tap each button to switch adjustment modes.
The skew values are included in the Transformation and CropInfo returned by the delegate, so you can persist and restore them via presetTransformationType.
You can optionally customize the appearance of the type selector buttons through SlideDialConfig:
typeButtonSize — diameter of each circular button (default: 48)typeButtonSpacing — spacing between buttons (default: 16)activeColor — color for the selected button ring and value textinactiveColor — color for unselected buttonspointerColor — color of the center pointer on the rulerskewLimitation — maximum skew angle in degrees (default: 30)Set the appearance mode to control the overall look of the crop UI:
var config = Mantis.Config()
config.appearanceMode = .forceLight // or .forceDark (default), .system
let cropViewController = Mantis.cropViewController(image: yourImage, config: config)
public enum AppearanceMode {
/// Always use dark appearance (default, backward compatible)
case forceDark
/// Always use light appearance
case forceLight
/// Follow system light/dark mode setting
case system
}
.forceDark is the default, keeping the existing dark-themed behavior..forceLight uses a light color scheme similar to Apple Photos in light mode..system dynamically adapts to the user's system-wide light/dark mode setting.The appearance mode affects all UI components including the toolbar, dimming overlay, rotation dial, type selector, and ratio item views.
Mantis ships with built-in localizations for Arabic, Chinese (Simplified and Traditional), Dutch, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish, and Turkish.
fig 1
SwiftUI project: please check this discussion.
Static frameworks: if you use static frameworks in CocoaPods, you need to add the code below in order to find the correct resource bundle.
Mantis.locateResourceBundle(by: Self.self)
By default Mantis uses its built-in localization tables, and not every language is supported out of the box (see fig 1).
If your app supports languages that are not built in, you can define your own strings table, localize it in your application target or framework, and point Mantis at it.
Important: first create a strings file with these keys:
"Mantis.Done" = "";
"Mantis.Cancel" = "";
"Mantis.Reset" = "";
"Mantis.Original" = "";
"Mantis.Square" = "";
"Mantis.Straighten" = "";
"Mantis.Horizontal" = "";
"Mantis.Vertical" = "";
Then configure Mantis:
let config = Mantis.Config()
config.localizationConfig.bundle = // a bundle where the strings file is located
config.localizationConfig.tableName = // the localized strings file name within the bundle
If needed you can subclass CropViewController:
class CustomViewController: CropViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do your custom logic here.
// The MantisExample project also has a showcase for a CustomViewController.
}
}
To get an instance, Mantis provides a factory method:
let cropViewController: CustomViewController = Mantis.cropViewController(image: image, config: config)
Mantis 3.0 contains a small set of breaking changes; most apps only need the renames below. Everything else — including the binding-based SwiftUI ImageCropperView — is source compatible.
CropViewConfig propertiesThese four properties had been deprecated forwarding shims; use the replacements:
| Removed in 3.0 | Use instead |
|---|---|
cropViewConfig.cropBoxHotAreaUnit | cropViewConfig.cropAuxiliaryIndicatorConfig.cropBoxHotAreaUnit |
cropViewConfig.disableCropBoxDeformation | cropViewConfig.cropAuxiliaryIndicatorConfig.disableCropBoxDeformation |
cropViewConfig.cropAuxiliaryIndicatorStyle | cropViewConfig.cropAuxiliaryIndicatorConfig.style |
cropViewConfig.showRotationDial | cropViewConfig.showAttachedRotationControlView |
CropInfoFive view-reconstruction fields (skewSublayerTransform, scrollContentOffset, scrollBoundsSize, imageContainerFrame, scrollViewTransform) moved out of the public struct and its init; they were pure view-hierarchy plumbing for the perspective and large-image crop paths.
CropInfo back into Mantis.crop(image:by:) (from the delegate or getCropInfo()): no change, including across value copies.CropInfo from persisted semantic fields via the public init for offline re-cropping: no change for the standard path. The perspective-skew and maxImagePixelCount overflow paths return nil for a rebuilt CropInfo — the same outcome the previous zero-value guards produced. As of 3.1, prefer persisting the Codable CropInfo Mantis produced — see Persisting and restoring crops.Transformation and feed it back via presetTransformationType = .presetInfo(info:) — that path is unchanged.Mantis.cropViewController(image:config:) factory now correctly wires up undo/redo when config.enableUndoRedo = true; previously only the setupCropViewController path did.Mantis provides two demo projects:
ImageCropper + CropSession API (Mantis 3.0+): normal crop with restore, crop shapes, fixed ratio, slide dial, perspective correction, and a custom toolbar driven by session state.ImageCropperView, kept as a 2.x migration reference.Set modalPresentationStyle = .fullScreen on the crop view controller (or its navigation controller) before presenting it. On iOS 13+ the default sheet presentation style does not work with the crop UI.
Use config.cropViewConfig.cropShapeType = .circle() (UIKit) or .cropShape(.circle) (SwiftUI). The exported UIImage is cut to the circle with a transparent background. If you want the exported image to stay rectangular and only show a circular mask in the UI, use .circle(maskOnly: true).
Two options:
Codable CropInfo (Mantis 3.1+) and pass it to Mantis.crop(image:by:) later.Transformation and restore it via config.cropViewConfig.presetTransformationType = .presetInfo(info:).Yes — Mantis supports iOS and Mac Catalyst, including localized Catalyst menus for undo/redo and independent undo histories for multiple croppers (e.g. iPad multi-window).
Set config.cropMode = .async to crop off the main thread, and/or set config.cropViewConfig.maxImagePixelCount to cap the output pixel count for very large photos.
Below are apps that use the Mantis framework. If your app also uses Mantis and you'd like it showcased here, please submit a PR following the existing format.
Pictopoem Where Images Whisper Poems | Text Behind Me Add Depth to Your Photos |
|---|
If Mantis saves you time, consider becoming a sponsor through GitHub Sponsors.
Mantis is released under the MIT License.
142 followers · starred Jul 2021
240 followers · starred Feb 2025
444 followers · starred Feb 2020
14 followers · starred Oct 2024