├── .swift-version
├── Mintfile
├── Resources
└── ExampleA.png
├── Examples
└── MortarDemo
│ ├── MortarDemo
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Info.plist
│ ├── StorageEngine.swift
│ ├── AppDelegate.swift
│ ├── DemoPages
│ │ ├── LayoutFeatures.swift
│ │ ├── ReactiveFeatures.swift
│ │ ├── BasicManagedTableView.swift
│ │ └── OriginalViewController.swift
│ └── MainMenuViewController.swift
│ └── MortarDemo.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── swiftpm
│ │ └── Package.resolved
│ └── project.pbxproj
├── Makefile
├── CHANGELOG.md
├── Mortar
├── LayoutGuide+MortarCoordinate.swift
├── Exports.swift
├── ManagedViews
│ ├── TableView
│ │ ├── ManagedTableViewSection.swift
│ │ ├── ManagedTableViewHeaderFooterView.swift
│ │ ├── ManagedTableViewCell.swift
│ │ ├── StandardCells
│ │ │ └── StandardTableViewCell.swift
│ │ ├── ManagedTableViewDelegation.swift
│ │ └── ManagedTableView.swift
│ ├── CollectionView
│ │ ├── ManagedCollectionViewSection.swift
│ │ ├── ManagedCollectionViewHeaderFooterView.swift
│ │ ├── ManagedCollectionViewCell.swift
│ │ └── ManagedCollectionView.swift
│ └── ManagedViewCommon.swift
├── MortarError.swift
├── MortarObjectAttachments.swift
├── MortarCGFloatable.swift
├── View+MortarCoordinate.swift
├── MortarViewAliases.swift
├── MortarScrollView.swift
├── View+LayoutStack.swift
├── MortarCoordinate.swift
├── MortarAnchorProvider.swift
├── MortarConstraint.swift
├── MortarAxisPriorities.swift
├── MortarStyle.swift
├── MortarConstraintGroup.swift
├── MortarReactive.swift
├── View+ResultBuilder.swift
├── MortarTextDelegation.swift
├── MortarGestureRecognizer.swift
├── Operators.swift
└── Mortar.swift
├── bin
└── githooks
│ └── pre-commit
├── MortarTests
├── TestUtilities.swift
└── MortarLayoutTests.swift
├── CONTRIBUTING.md
├── Package.swift
├── .swiftformat
├── LICENSE
├── .gitignore
├── WHY.md
├── README.md
└── AGENTS.md
/.swift-version:
--------------------------------------------------------------------------------
1 | 6.0
2 |
--------------------------------------------------------------------------------
/Mintfile:
--------------------------------------------------------------------------------
1 | nicklockwood/SwiftFormat@0.55.5
2 |
--------------------------------------------------------------------------------
/Resources/ExampleA.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jmfieldman/Mortar/HEAD/Resources/ExampleA.png
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | bootstrap:
2 | @brew install mint
3 | @mint install nicklockwood/SwiftFormat
4 | @cp bin/githooks/pre-commit .git/hooks/.
5 |
6 | format:
7 | @mint run swiftformat --config .swiftformat .
8 |
9 | .PHONY: bootstrap \
10 | format
11 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Mortar Changelog
2 |
3 | ## 3.1.0
4 |
5 | * Renamed HStackView/VStackView to UIHStack/UIVStack
6 |
7 | ## 3.0.0
8 |
9 | * Mortar has been completely rewritten for 3.0, and there is no backwards compatibility at all. 3.0.0 is the first release of the new Mortar library.
--------------------------------------------------------------------------------
/Mortar/LayoutGuide+MortarCoordinate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LayoutGuide+MortarCoordinate.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | public extension MortarLayoutGuide {
7 | var layout: MortarAnchorProvider {
8 | .init(item: self)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Mortar/Exports.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Exports.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | @_exported import Combine
7 | @_exported import CombineEx
8 |
9 | #if os(iOS) || os(tvOS)
10 | @_exported import UIKit
11 | #else
12 | @_exported import AppKit
13 | #endif
14 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | UILaunchScreen
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/StorageEngine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StorageEngine.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 |
8 | /// This is a default environment for use with PersistentProperty
9 | let storageEnvironment = FileBasedPersistentPropertyEnvironment(environmentId: "default_environment")
10 |
--------------------------------------------------------------------------------
/bin/githooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | disable_swiftformat=$(git config --bool hooks.disable-swiftformat)
4 | if [[ "$disable_swiftformat" == true ]]; then
5 | exit 0
6 | fi
7 |
8 | # Swiftformat
9 | git diff -z --diff-filter=d --staged --name-only -- '*.swift' | xargs -0 mint run swiftformat --config .swiftformat
10 | git diff -z --diff-filter=d --staged --name-only -- '*.swift' | xargs -0 git add
11 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "62588cb6a0c87175bf72fc3a97199bf94938723bd8cd3fbf3cc290f44adaa0b9",
3 | "pins" : [
4 | {
5 | "identity" : "combineex",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/jmfieldman/CombineEx.git",
8 | "state" : {
9 | "revision" : "a0fb0ca15ba533db083c938c3a412b83687d9b2b",
10 | "version" : "0.0.17"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/MortarTests/TestUtilities.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestUtilities.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | @testable import Mortar
7 | import XCTest
8 |
9 | #if os(iOS) || os(tvOS)
10 | typealias TestLabel = UILabel
11 | #else
12 | typealias TestLabel = NSTextView
13 | extension NSTextView {
14 | var text: String? {
15 | get { "" }
16 | set { _ = newValue }
17 | }
18 | }
19 |
20 | extension NSView {
21 | func layoutIfNeeded() {
22 | layoutSubtreeIfNeeded()
23 | }
24 |
25 | var backgroundColor: NSColor {
26 | get { .red }
27 | set { _ = newValue }
28 | }
29 | }
30 | #endif
31 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Setup
4 |
5 | After cloning this repo locally, make sure to run `make bootstrap` to install mint, the appropriate command-line tools, and install git hooks for formatting.
6 |
7 | ```sh
8 | $ make bootstrap
9 | ```
10 |
11 | ## Contributors
12 |
13 | ### Mortar 3
14 |
15 | * [Jason Fieldman](https://github.com/jmfieldman) - Maintainer
16 |
17 | ### Mortar 1, 2
18 |
19 | * [Jason Fieldman](https://github.com/jmfieldman) - Maintainer
20 | * [Brian Kenny](https://github.com/BrianKenny) - iOS 7 compatibility, VFL updates
21 | * [Adam Cooper](https://github.com/AdamBCo) - Swift 4.2 support
22 | * [Steffen Matthischke](https://github.com/HeEAaD) - Swift 5.0 support
23 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import UIKit
7 |
8 | @main
9 | class AppDelegate: UIResponder, UIApplicationDelegate {
10 | var window: UIWindow?
11 |
12 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
13 | // Override point for customization after application launch.
14 | window = UIWindow()
15 | window?.rootViewController = UINavigationController(rootViewController: MainMenuViewController())
16 | window?.makeKeyAndVisible()
17 |
18 | return true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "idiom" : "universal",
16 | "platform" : "ios",
17 | "size" : "1024x1024"
18 | },
19 | {
20 | "appearances" : [
21 | {
22 | "appearance" : "luminosity",
23 | "value" : "tinted"
24 | }
25 | ],
26 | "idiom" : "universal",
27 | "platform" : "ios",
28 | "size" : "1024x1024"
29 | }
30 | ],
31 | "info" : {
32 | "author" : "xcode",
33 | "version" : 1
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.9
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Mortar",
7 | platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17)],
8 | products: [
9 | .library(
10 | name: "Mortar",
11 | targets: ["Mortar"]
12 | ),
13 | ],
14 | dependencies: [
15 | .package(
16 | url: "https://github.com/jmfieldman/CombineEx.git",
17 | from: "0.0.46"
18 | ),
19 | ],
20 | targets: [
21 | .target(
22 | name: "Mortar",
23 | dependencies: ["CombineEx"],
24 | path: "Mortar"
25 | ),
26 | .testTarget(
27 | name: "MortarTests",
28 | dependencies: ["Mortar"],
29 | path: "MortarTests"
30 | ),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | # format options
2 | --allman false
3 | --elseposition same-line
4 | --patternlet hoist
5 | --indent 4
6 | --indentcase false
7 | --ifdef no-indent
8 | --xcodeindentation false
9 | --linebreaks lf
10 | --decimalgrouping 3,6
11 | --binarygrouping 4,8
12 | --octalgrouping 4,8
13 | --hexgrouping 4,8
14 | --fractiongrouping disabled
15 | --exponentgrouping disabled
16 | --hexliteralcase uppercase
17 | --exponentcase uppercase
18 | --ranges spaced
19 | --self init-only
20 | --semicolons never
21 | --importgrouping alphabetized
22 | --operatorfunc spaced
23 | --commas always
24 | --trimwhitespace always
25 | --stripunusedargs closure-only
26 | --empty void
27 | --wraparguments before-first
28 | --wrapcollections before-first
29 | --closingparen balanced
30 |
31 | # rule options
32 | --disable redundantRawValues
33 |
34 | # Header
35 | --header \n {file}\n Copyright © {created.year} Jason Fieldman.\n
36 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/ManagedTableViewSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedTableViewSection.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | public struct ManagedTableViewSection: Identifiable {
9 | public let id: String
10 | public let header: (any ManagedTableViewHeaderFooterViewModel)?
11 | public let rows: [any ManagedTableViewCellModel]
12 | public let footer: (any ManagedTableViewHeaderFooterViewModel)?
13 |
14 | public init(
15 | id: String = UUID().uuidString,
16 | header: (any ManagedTableViewHeaderFooterViewModel)? = nil,
17 | rows: [any ManagedTableViewCellModel],
18 | footer: (any ManagedTableViewHeaderFooterViewModel)? = nil
19 | ) {
20 | self.id = id
21 | self.header = header
22 | self.rows = rows
23 | self.footer = footer
24 | }
25 | }
26 |
27 | #endif
28 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/CollectionView/ManagedCollectionViewSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedCollectionViewSection.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | public struct ManagedCollectionViewSection: Identifiable {
9 | public let id: String
10 | public let header: (any ManagedCollectionReusableViewModel)?
11 | public let items: [any ManagedCollectionViewCellModel]
12 | public let footer: (any ManagedCollectionReusableViewModel)?
13 |
14 | public init(
15 | id: String = UUID().uuidString,
16 | header: (any ManagedCollectionReusableViewModel)? = nil,
17 | items: [any ManagedCollectionViewCellModel],
18 | footer: (any ManagedCollectionReusableViewModel)? = nil
19 | ) {
20 | self.id = id
21 | self.header = header
22 | self.items = items
23 | self.footer = footer
24 | }
25 | }
26 |
27 | #endif
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Jason Fieldman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Mortar/MortarError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarError.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 |
8 | public enum MortarError {
9 | static let errorSubject = MutableProperty(nil)
10 | static let installBag = NSObject()
11 |
12 | static func emit(_ message: String) {
13 | errorSubject.value = message
14 | }
15 |
16 | /// This publisher is exposed to the public and will emit Mortar errors.
17 | /// Any emission on this property is considered fatal and should trigger
18 | /// some kind of hard error in debug mode. In production, behavior of the
19 | /// Mortar APIs after an error is emitted is undefined.
20 | public static let errorProperty = Property(errorSubject)
21 |
22 | /// This helper allows you to install a block-based listener to Mortar errors
23 | /// without requiring any explicit publisher-based syntax. The hook lasts for
24 | /// the lifetime of the process.
25 | public static func install(hook: @escaping @Sendable (String) -> Void) {
26 | errorSubject
27 | .compactMap(\.self)
28 | .sink(
29 | duringLifetimeOf: installBag,
30 | receiveValue: hook
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/ManagedTableViewHeaderFooterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedTableViewHeaderFooterView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public protocol ManagedTableViewHeaderFooterViewModel {
11 | associatedtype Header: ManagedTableViewHeaderFooterView
12 | }
13 |
14 | public protocol ManagedTableViewHeaderFooterView: UITableViewHeaderFooterView {
15 | associatedtype Model: ManagedTableViewHeaderFooterViewModel
16 |
17 | /// This function must be implemented by `ManagedTableViewHeaderFooterView` classes.
18 | /// It is guaranteed to be called only once, on the main thread, *after*
19 | /// the very first time the model was updated. The `model` property will
20 | /// be ready for use, and guaranteed to have the first Model value immediately
21 | /// available.
22 | @MainActor func configureView()
23 | }
24 |
25 | extension ManagedTableViewHeaderFooterView {
26 | @MainActor func update(model: Model) {
27 | var created = false
28 | __AssociatedMutableProperty(self, Model.self, model, &created).value = model
29 | if created {
30 | configureView()
31 | }
32 | }
33 |
34 | public var model: Property {
35 | __AssociatedProperty(self, Model.self)
36 | }
37 | }
38 |
39 | #endif
40 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/CollectionView/ManagedCollectionViewHeaderFooterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedCollectionViewHeaderFooterView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public protocol ManagedCollectionReusableViewModel {
11 | associatedtype ReusableView: ManagedCollectionReusableView
12 | }
13 |
14 | public protocol ManagedCollectionReusableView: UICollectionReusableView {
15 | associatedtype Model: ManagedTableViewHeaderFooterViewModel
16 |
17 | /// This function must be implemented by `ManagedCollectionReusableView` classes.
18 | /// It is guaranteed to be called only once, on the main thread, *after*
19 | /// the very first time the model was updated. The `model` property will
20 | /// be ready for use, and guaranteed to have the first Model value immediately
21 | /// available.
22 | @MainActor func configureView()
23 | }
24 |
25 | extension ManagedCollectionReusableView {
26 | @MainActor func update(model: Model) {
27 | var created = false
28 | __AssociatedMutableProperty(self, Model.self, model, &created).value = model
29 | if created {
30 | configureView()
31 | }
32 | }
33 |
34 | public var model: Property {
35 | __AssociatedProperty(self, Model.self)
36 | }
37 | }
38 |
39 | #endif
40 |
--------------------------------------------------------------------------------
/Mortar/MortarObjectAttachments.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarObjectAttachments.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import Foundation
7 |
8 | private var kPermanentObjDictAssociationKey = 0
9 |
10 | /// Returns a dictionary used for storing permanently-associated values, creating one if it doesn't exist.
11 | ///
12 | /// - Parameter object: The object to associate the mutable set with.
13 | /// - Returns: An `NSMutableDictionary` instance associated with the given object.
14 | func __AnyObjectPermanentObjDictStorage(_ object: AnyObject) -> NSMutableDictionary {
15 | if let dict = objc_getAssociatedObject(object, &kPermanentObjDictAssociationKey) as? NSMutableDictionary {
16 | return dict
17 | }
18 |
19 | let dict = NSMutableDictionary()
20 | objc_setAssociatedObject(object, &kPermanentObjDictAssociationKey, dict, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
21 | return dict
22 | }
23 |
24 | extension NSObject {
25 | func permanentlyAssociate(_ t: T) {
26 | objc_sync_enter(self)
27 | defer { objc_sync_exit(self) }
28 |
29 | let storage = __AnyObjectPermanentObjDictStorage(self)
30 | let key = ObjectIdentifier(T.self)
31 |
32 | var array = storage[key] as? NSMutableArray
33 | if array == nil {
34 | array = NSMutableArray(capacity: 32)
35 | storage[key] = array
36 | }
37 |
38 | array?.add(t)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/CollectionView/ManagedCollectionViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedCollectionViewCell.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import CombineEx
9 | import UIKit
10 |
11 | public protocol ManagedCollectionViewCellModel: Identifiable {
12 | associatedtype Cell: ManagedCollectionViewCell
13 | var onSelect: ((ManagedCollectionView, IndexPath) -> Void)? { get }
14 | }
15 |
16 | public extension ManagedCollectionViewCellModel {
17 | var onSelect: ((ManagedCollectionView, IndexPath) -> Void)? { nil }
18 | }
19 |
20 | public protocol ManagedCollectionViewCell: UICollectionViewCell {
21 | associatedtype Model: ManagedCollectionViewCellModel
22 |
23 | /// This function must be implemented by `ManagedTableViewCell` classes.
24 | /// It is guaranteed to be called only once, on the main thread, *after*
25 | /// the very first time the model was updated. The `model` property will
26 | /// be ready for use, and guaranteed to have the first Model value immediately
27 | /// available.
28 | @MainActor func configureView()
29 | }
30 |
31 | extension ManagedCollectionViewCell {
32 | @MainActor func update(model: Model) {
33 | var created = false
34 | __AssociatedMutableProperty(self, Model.self, model, &created).value = model
35 | if created {
36 | configureView()
37 | }
38 | }
39 |
40 | public var model: Property {
41 | __AssociatedProperty(self, Model.self)
42 | }
43 | }
44 |
45 | #endif
46 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/ManagedTableViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedTableViewCell.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import CombineEx
9 | import UIKit
10 |
11 | public protocol ManagedTableViewCellModel: Identifiable {
12 | associatedtype Cell: ManagedTableViewCell
13 | var onSelect: ((ManagedTableView, IndexPath) -> Void)? { get }
14 | var preventHeightCaching: Bool { get }
15 | }
16 |
17 | public extension ManagedTableViewCellModel {
18 | var onSelect: ((ManagedTableView, IndexPath) -> Void)? { nil }
19 | var preventHeightCaching: Bool { false }
20 | }
21 |
22 | public protocol ManagedTableViewCell: UITableViewCell {
23 | associatedtype Model: ManagedTableViewCellModel
24 |
25 | /// This function must be implemented by `ManagedTableViewCell` classes.
26 | /// It is guaranteed to be called only once, on the main thread, *after*
27 | /// the very first time the model was updated. The `model` property will
28 | /// be ready for use, and guaranteed to have the first Model value immediately
29 | /// available.
30 | @MainActor func configureView()
31 | }
32 |
33 | extension ManagedTableViewCell {
34 | @MainActor func update(model: Model) {
35 | var created = false
36 | __AssociatedMutableProperty(self, Model.self, model, &created).value = model
37 | if created {
38 | configureView()
39 | }
40 | }
41 |
42 | public var model: Property {
43 | __AssociatedProperty(self, Model.self)
44 | }
45 | }
46 |
47 | #endif
48 |
--------------------------------------------------------------------------------
/Mortar/MortarCGFloatable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarCGFloatable.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | public protocol MortarCGFloatable: MortarCoordinateConvertible {
7 | var floatValue: CGFloat { get }
8 | }
9 |
10 | extension CGFloat: MortarCGFloatable {
11 | public var floatValue: CGFloat { self }
12 | }
13 |
14 | extension Int: MortarCGFloatable {
15 | public var floatValue: CGFloat { CGFloat(self) }
16 | }
17 |
18 | extension UInt: MortarCGFloatable {
19 | public var floatValue: CGFloat { CGFloat(self) }
20 | }
21 |
22 | extension Int64: MortarCGFloatable {
23 | public var floatValue: CGFloat { CGFloat(self) }
24 | }
25 |
26 | extension UInt64: MortarCGFloatable {
27 | public var floatValue: CGFloat { CGFloat(self) }
28 | }
29 |
30 | extension UInt32: MortarCGFloatable {
31 | public var floatValue: CGFloat { CGFloat(self) }
32 | }
33 |
34 | extension Int32: MortarCGFloatable {
35 | public var floatValue: CGFloat { CGFloat(self) }
36 | }
37 |
38 | extension UInt16: MortarCGFloatable {
39 | public var floatValue: CGFloat { CGFloat(self) }
40 | }
41 |
42 | extension Int16: MortarCGFloatable {
43 | public var floatValue: CGFloat { CGFloat(self) }
44 | }
45 |
46 | extension UInt8: MortarCGFloatable {
47 | public var floatValue: CGFloat { CGFloat(self) }
48 | }
49 |
50 | extension Int8: MortarCGFloatable {
51 | public var floatValue: CGFloat { CGFloat(self) }
52 | }
53 |
54 | extension Double: MortarCGFloatable {
55 | public var floatValue: CGFloat { CGFloat(self) }
56 | }
57 |
58 | extension Float: MortarCGFloatable {
59 | public var floatValue: CGFloat { CGFloat(self) }
60 | }
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | .DS_Store
6 | .swiftpm
7 | Package.resolved
8 |
9 | .vscode
10 |
11 | ## Build generated
12 | build/
13 | DerivedData
14 |
15 | ## Various settings
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 | xcuserdata
25 |
26 | ## Other
27 | *.xccheckout
28 | *.moved-aside
29 | *.xcuserstate
30 | *.xcscmblueprint
31 |
32 | ## Obj-C/Swift specific
33 | *.hmap
34 | *.ipa
35 |
36 | ## Playgrounds
37 | timeline.xctimeline
38 | playground.xcworkspace
39 |
40 | # Swift Package Manager
41 | #
42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
43 | # Packages/
44 | .build/
45 |
46 | # CocoaPods
47 | #
48 | # We recommend against adding the Pods directory to your .gitignore. However
49 | # you should judge for yourself, the pros and cons are mentioned at:
50 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
51 | #
52 | # Pods/
53 |
54 | # Carthage
55 | #
56 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
57 | # Carthage/Checkouts
58 |
59 | Carthage/Build
60 |
61 | # fastlane
62 | #
63 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
64 | # screenshots whenever they are needed.
65 | # For more information about the recommended setup visit:
66 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md
67 |
68 | fastlane/report.xml
69 | fastlane/screenshots
70 |
--------------------------------------------------------------------------------
/Mortar/View+MortarCoordinate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+MortarCoordinate.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | public extension MortarView {
7 | var layout: MortarAnchorProvider {
8 | .init(item: self)
9 | }
10 |
11 | var safeLayout: MortarAnchorProvider {
12 | .init(item: safeAreaLayoutGuide)
13 | }
14 |
15 | var keyboardLayout: MortarAnchorProvider {
16 | .init(item: keyboardLayoutGuide)
17 | }
18 |
19 | var parentLayout: MortarAnchorProvider {
20 | .init(item: MortarRelativeAnchor.parent(self) { $0 })
21 | }
22 |
23 | var parentSafeAreaLayout: MortarAnchorProvider {
24 | .init(item: MortarRelativeAnchor.parent(self) { $0.safeAreaLayoutGuide })
25 | }
26 |
27 | var parentKeyboardLayout: MortarAnchorProvider {
28 | .init(item: MortarRelativeAnchor.parent(self) { $0.keyboardLayoutGuide })
29 | }
30 |
31 | func referencedLayout(_ referenceId: String) -> MortarAnchorProvider {
32 | .init(item: MortarRelativeAnchor.reference(referenceId) { $0 })
33 | }
34 |
35 | func referencedSafeAreaLayout(_ referenceId: String) -> MortarAnchorProvider {
36 | .init(item: MortarRelativeAnchor.reference(referenceId) { $0.safeAreaLayoutGuide })
37 | }
38 |
39 | func referencedLeyboardLayout(_ referenceId: String) -> MortarAnchorProvider {
40 | .init(item: MortarRelativeAnchor.reference(referenceId) { $0.keyboardLayoutGuide })
41 | }
42 |
43 | var layoutReferenceId: String? {
44 | get {
45 | MortarMainThreadLayoutStack.shared.layoutReferenceIdFor(view: self)
46 | }
47 | set {
48 | MortarMainThreadLayoutStack.shared.addLayoutReference(id: newValue, view: self)
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/ManagedViewCommon.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedViewCommon.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 | import Foundation
8 |
9 | #if os(iOS) || os(tvOS)
10 | import UIKit
11 | #else
12 | import AppKit
13 | #endif
14 |
15 | // MARK: ArbitrarilyIdentifiable
16 |
17 | /// Managed models can conform to `ArbitrarilyIdentifiable` to provide arbitrary
18 | /// `id` values. This is useful for models of non-collection-esque data such as
19 | /// settings screens.
20 | public protocol ArbitrarilyIdentifiable: Identifiable {}
21 |
22 | public extension ArbitrarilyIdentifiable {
23 | var id: String { UUID().uuidString }
24 | }
25 |
26 | // MARK: Associated Properties
27 |
28 | private var kAssociatedPropertyKey = 0
29 | private var kAssociatedMutablePropertyKey = 0
30 |
31 | func __AssociatedMutableProperty(_ object: AnyObject, _ type: T.Type, _ initialValue: T, _ created: inout Bool) -> MutableProperty {
32 | if let property = objc_getAssociatedObject(object, &kAssociatedMutablePropertyKey) as? MutableProperty {
33 | return property
34 | }
35 |
36 | created = true
37 | let property = MutableProperty(initialValue)
38 | objc_setAssociatedObject(object, &kAssociatedMutablePropertyKey, property, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
39 | objc_setAssociatedObject(object, &kAssociatedPropertyKey, Property(property), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
40 | return property
41 | }
42 |
43 | func __AssociatedProperty(_ object: AnyObject, _ type: T.Type) -> Property {
44 | guard let property = objc_getAssociatedObject(object, &kAssociatedPropertyKey) as? Property else {
45 | fatalError("Cannot access __AssociatedProperty before __AssociatedMutableProperty. Do not access the model in the initializer")
46 | }
47 | return property
48 | }
49 |
--------------------------------------------------------------------------------
/Mortar/MortarViewAliases.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarViewAliases.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | /// A type alias for UIContainer pointing to MortarView. `UIContainer` is more
9 | /// descriptive of what container views do vs. SwiftUI's `ZStack`
10 | public typealias UIContainer = MortarView
11 |
12 | /// A horizontal stack view that arranges its subviews in a horizontal line.
13 | public class UIHStack: MortarStackView {
14 | /// Initializes an instance of UIHStack with a specified frame.
15 | ///
16 | /// - Parameter frame: The frame rectangle for the view, measured in points.
17 | override public init(frame: CGRect) {
18 | super.init(frame: frame)
19 | axis = .horizontal
20 | }
21 |
22 | /// This initializer is unavailable and will always cause a runtime error if called.
23 | ///
24 | /// - Parameter coder: An NSCoder object that you use to decode your archived data.
25 | @available(*, unavailable)
26 | required init(coder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 | }
30 |
31 | /// A vertical stack view that arranges its subviews in a vertical line.
32 | public class UIVStack: MortarStackView {
33 | /// Initializes an instance of UIVStack with a specified frame.
34 | ///
35 | /// - Parameter frame: The frame rectangle for the view, measured in points.
36 | override public init(frame: CGRect) {
37 | super.init(frame: frame)
38 | axis = .vertical
39 | }
40 |
41 | /// This initializer is unavailable and will always cause a runtime error if called.
42 | ///
43 | /// - Parameter coder: An NSCoder object that you use to decode your archived data.
44 | @available(*, unavailable)
45 | required init(coder: NSCoder) {
46 | fatalError("init(coder:) has not been implemented")
47 | }
48 | }
49 |
50 | #endif
51 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/StandardCells/StandardTableViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StandardTableViewCell.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import CombineEx
9 | import UIKit
10 |
11 | public final class StandardTableViewCell: UITableViewCell, ManagedTableViewCell {
12 | public struct Model: ManagedTableViewCellModel, ArbitrarilyIdentifiable {
13 | public typealias Cell = StandardTableViewCell
14 |
15 | public let id: String
16 | public let text: String
17 | public let textStyle: TextStyle?
18 | public let image: UIImage?
19 | public let accessoryType: UITableViewCell.AccessoryType
20 | public let accessoryView: (@MainActor () -> UIView?)?
21 | public let onSelect: ((ManagedTableView, IndexPath) -> Void)?
22 |
23 | public init(
24 | id: String = UUID().uuidString,
25 | text: String,
26 | textStyle: TextStyle? = nil,
27 | image: UIImage? = nil,
28 | accessoryType: UITableViewCell.AccessoryType = .none,
29 | accessoryView: (@MainActor () -> UIView?)? = nil,
30 | onSelect: ((ManagedTableView, IndexPath) -> Void)? = nil
31 | ) {
32 | self.id = id
33 | self.text = text
34 | self.textStyle = textStyle
35 | self.image = image
36 | self.accessoryType = accessoryType
37 | self.accessoryView = accessoryView
38 | self.onSelect = onSelect
39 | }
40 | }
41 |
42 | public func configureView() {
43 | textLabel?.bind(\.textStyle) <~ model.map(\.textStyle)
44 | textLabel?.bind(\.styledText) <~ model.map(\.text)
45 | imageView?.bind(\.isHidden) <~ model.map { $0.image == nil }
46 | imageView?.bind(\.image) <~ model.map(\.image)
47 |
48 | bind(\.accessoryType) <~ model.map(\.accessoryType)
49 | bind(\.accessoryView) <~ model.map { model in
50 | MainActor.assumeIsolated { model.accessoryView?() }
51 | }
52 | }
53 | }
54 |
55 | #endif
56 |
--------------------------------------------------------------------------------
/WHY.md:
--------------------------------------------------------------------------------
1 | # Why
2 |
3 | ### Subjective Opinion
4 |
5 | I have not used SwiftUI much in a professional capacity.
6 |
7 | The last time was making the Bird App Clip, which reused a lot of our existing ReactiveSwift tooling. So a lot of the "View Model" code was translating from Property/SignalProducers into the @Published values from the older SwiftUI syntax. I was not really impressed by this, nor the constant incredulity I would feel trying to figure out the correct SwiftUI syntax for various UI goals.
8 |
9 | I then published an app to the App Store (before @Observable, no structured concurrency) which used Combine. I really did like that I could compose anonymous view hierarchies, and I did like that the SwiftUI semantics really forced me to isolate my view and view model code into separate classes.
10 |
11 | But I really do hate that the concept that the entire view hierarchy is just View structs. It feels like an overcorrection. And I really dislike the almost bespoke-per-use-case API that is really hard to internalize, coming from UIKit.
12 |
13 | In a few random side dabblings in SwiftUI, I always hit some wall attempting to get the UI to do a specific task. One time it was the keyboard bouncing erratically when switching between input views. One time I couldn't get the layout to work with various image aspect ratios in a horizontal stack (because there was no concept of hugging/compression priorities.)
14 |
15 | I also hit random performance issues. And when they hit, it's nearly unrecoverable from a morale perspective. One time I was using SwiftUI to build a level editor for Theseus, and just updating the current square state as the user moved the mouse seemed like it was causing the entire app to redraw every single view on the screen. I'm 100% sure that was my fault, but the fact that the framework allowed me to shoot myself in the foot like that seemed like it should take a bit of the blame.
16 |
17 | Anyway, the main takeaway is that I really love coding anonymous view hierarchies with reactive semantics, and I love being able to cleanly isolate the business logic I/O in its own testable class. So I made this library to brings both of those to UIKit (so I can keep everything else that UIKit does better than SwiftUI.)
18 |
19 | Jason
--------------------------------------------------------------------------------
/Mortar/MortarScrollView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarScrollView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public extension UIScrollView {
11 | func installReactiveKeyboardInsets() {
12 | NotificationCenter.default.addObserver(
13 | self,
14 | selector: #selector(_handleKeyboardShow(notification:)),
15 | name: UIResponder.keyboardWillShowNotification,
16 | object: nil
17 | )
18 |
19 | NotificationCenter.default.addObserver(
20 | self,
21 | selector: #selector(_handleKeyboardHide(notification:)),
22 | name: UIResponder.keyboardWillHideNotification,
23 | object: nil
24 | )
25 | }
26 |
27 | @objc func _handleKeyboardShow(notification: Notification) {
28 | guard let userInfo = notification.userInfo,
29 | let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
30 | let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
31 | let animationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
32 | else {
33 | return
34 | }
35 |
36 | let insets = UIEdgeInsets(top: 0, left: 0, bottom: keyboardFrame.height, right: 0)
37 | let animationOptions = UIView.AnimationOptions(rawValue: animationCurve << 16)
38 | UIView.animate(withDuration: animationDuration, delay: 0, options: animationOptions) {
39 | self.contentInset = insets
40 | self.scrollIndicatorInsets = insets
41 | }
42 | }
43 |
44 | @objc func _handleKeyboardHide(notification: Notification) {
45 | guard let userInfo = notification.userInfo,
46 | let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
47 | let animationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
48 | else { return }
49 |
50 | let animationOptions = UIView.AnimationOptions(rawValue: animationCurve << 16)
51 | UIView.animate(withDuration: animationDuration, delay: 0, options: animationOptions) {
52 | self.contentInset = .zero
53 | self.scrollIndicatorInsets = .zero
54 | }
55 | }
56 | }
57 |
58 | #endif
59 |
--------------------------------------------------------------------------------
/Mortar/View+LayoutStack.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+LayoutStack.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | class MortarMainThreadLayoutStack {
7 | public static let shared = MortarMainThreadLayoutStack()
8 |
9 | private var stackDepth: Int = 0
10 | private var accumulator: [MortarConstraint] = []
11 | private var layoutReferences: [String: MortarView] = [:]
12 |
13 | private init() {
14 | DispatchQueue.main.setSpecific(key: key, value: value)
15 | accumulator.reserveCapacity(4096)
16 | layoutReferences.reserveCapacity(256)
17 | }
18 |
19 | func isMainThread() -> Bool {
20 | DispatchQueue.getSpecific(key: key) == value
21 | }
22 |
23 | static func execute(_ block: () -> Void) {
24 | shared.push()
25 | block()
26 | shared.pop()
27 | }
28 |
29 | private func push() {
30 | if !isMainThread() {
31 | MortarError.emit("Attempted to push layout stack off main thread")
32 | }
33 | stackDepth += 1
34 | }
35 |
36 | private func pop() {
37 | if !isMainThread() {
38 | MortarError.emit("Attempted to pop layout stack off main thread")
39 | }
40 | if stackDepth <= 0 {
41 | MortarError.emit("Attempted to pop layout stack with invalid depth: \(stackDepth)")
42 | }
43 | stackDepth -= 1
44 |
45 | // Flush the accumulator if the stack is empty
46 | if stackDepth == 0 {
47 | for item in accumulator {
48 | item.layoutConstraint?.isActive = item.source.startActivated
49 | }
50 |
51 | // Flush references
52 | accumulator.removeAll(keepingCapacity: true)
53 | layoutReferences.removeAll(keepingCapacity: true)
54 | }
55 | }
56 |
57 | func insideStack() -> Bool {
58 | stackDepth > 0
59 | }
60 |
61 | func accumulate(constraints: [MortarConstraint]) {
62 | accumulator.append(contentsOf: constraints)
63 | }
64 | }
65 |
66 | private let key = DispatchSpecificKey()
67 | private let value: UInt8 = 0
68 |
69 | // MARK: - Layout References
70 |
71 | extension MortarMainThreadLayoutStack {
72 | func addLayoutReference(id: String?, view: MortarView) {
73 | if let id {
74 | layoutReferences[id] = view
75 | } else if let existing = layoutReferenceIdFor(view: view) {
76 | layoutReferences[existing] = nil
77 | }
78 | }
79 |
80 | func viewForLayoutReference(id: String) -> MortarView? {
81 | layoutReferences[id]
82 | }
83 |
84 | func layoutReferenceIdFor(view: MortarView) -> String? {
85 | layoutReferences.first { $0.value === view }?.key
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Mortar/MortarCoordinate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarCoordinate.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | typealias MortarConstantTuple = (CGFloat, CGFloat, CGFloat, CGFloat)
7 |
8 | func tupleIndex(_ tuple: MortarConstantTuple, _ index: Int) -> CGFloat {
9 | switch index {
10 | case 0: tuple.0
11 | case 1: tuple.1
12 | case 2: tuple.2
13 | case 3: tuple.3
14 | default: tuple.0
15 | }
16 | }
17 |
18 | public struct MortarCoordinate {
19 | let item: Any?
20 | let attribute: MortarLayoutAttribute?
21 | let multiplier: CGFloat
22 | let constant: MortarConstantTuple
23 | let priority: MortarLayoutPriority
24 | let startActivated: Bool
25 |
26 | init(
27 | item: Any?,
28 | attribute: MortarLayoutAttribute?,
29 | multiplier: CGFloat,
30 | constant: MortarConstantTuple,
31 | priority: MortarLayoutPriority,
32 | startActivated: Bool = true
33 | ) {
34 | self.item = item
35 | self.attribute = attribute
36 | self.multiplier = multiplier
37 | self.constant = constant
38 | self.priority = priority
39 | self.startActivated = startActivated
40 | }
41 | }
42 |
43 | public protocol MortarCoordinateConvertible {
44 | var coordinate: MortarCoordinate { get }
45 | }
46 |
47 | public extension MortarCGFloatable {
48 | var coordinate: MortarCoordinate {
49 | .init(
50 | item: nil,
51 | attribute: nil,
52 | multiplier: 1,
53 | constant: (floatValue, 0, 0, 0),
54 | priority: .required
55 | )
56 | }
57 | }
58 |
59 | extension CGPoint: MortarCoordinateConvertible {
60 | public var coordinate: MortarCoordinate {
61 | .init(
62 | item: nil,
63 | attribute: nil,
64 | multiplier: 1,
65 | constant: (y, x, 0, 0),
66 | priority: .required
67 | )
68 | }
69 | }
70 |
71 | extension CGSize: MortarCoordinateConvertible {
72 | public var coordinate: MortarCoordinate {
73 | .init(
74 | item: nil,
75 | attribute: nil,
76 | multiplier: 1,
77 | constant: (width, height, 0, 0),
78 | priority: .required
79 | )
80 | }
81 | }
82 |
83 | extension MortarEdgeInsets: MortarCoordinateConvertible {
84 | public var coordinate: MortarCoordinate {
85 | .init(
86 | item: nil,
87 | attribute: nil,
88 | multiplier: 1,
89 | constant: (top, left, -bottom, -right),
90 | priority: .required
91 | )
92 | }
93 | }
94 |
95 | extension MortarView: MortarCoordinateConvertible {
96 | public var coordinate: MortarCoordinate {
97 | .init(
98 | item: self,
99 | attribute: nil,
100 | multiplier: 1,
101 | constant: (0, 0, 0, 0),
102 | priority: .required
103 | )
104 | }
105 | }
106 |
107 | extension MortarLayoutGuide: MortarCoordinateConvertible {
108 | public var coordinate: MortarCoordinate {
109 | .init(
110 | item: self,
111 | attribute: nil,
112 | multiplier: 1,
113 | constant: (0, 0, 0, 0),
114 | priority: .required
115 | )
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/Mortar/MortarAnchorProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarAnchorProvider.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | enum MortarRelativeAnchor {
7 | case parent(MortarView, (MortarView) -> Any)
8 | case reference(String, (MortarView) -> Any)
9 | }
10 |
11 | public struct MortarAnchorProvider {
12 | let item: Any
13 |
14 | func anchorCoordinate(_ attribute: MortarLayoutAttribute) -> MortarCoordinate {
15 | .init(
16 | item: item,
17 | attribute: attribute,
18 | multiplier: 1,
19 | constant: (0, 0, 0, 0),
20 | priority: .required
21 | )
22 | }
23 | }
24 |
25 | public extension MortarAnchorProvider {
26 | var left: MortarCoordinate { anchorCoordinate(.left) }
27 | var right: MortarCoordinate { anchorCoordinate(.right) }
28 | var top: MortarCoordinate { anchorCoordinate(.top) }
29 | var bottom: MortarCoordinate { anchorCoordinate(.bottom) }
30 | var leading: MortarCoordinate { anchorCoordinate(.leading) }
31 | var trailing: MortarCoordinate { anchorCoordinate(.trailing) }
32 | var centerX: MortarCoordinate { anchorCoordinate(.centerX) }
33 | var centerY: MortarCoordinate { anchorCoordinate(.centerY) }
34 | var baseline: MortarCoordinate { anchorCoordinate(.baseline) }
35 | var firstBaseline: MortarCoordinate { anchorCoordinate(.firstBaseline) }
36 | var lastBaseline: MortarCoordinate { anchorCoordinate(.lastBaseline) }
37 | var size: MortarCoordinate { anchorCoordinate(.size) }
38 | var width: MortarCoordinate { anchorCoordinate(.width) }
39 | var height: MortarCoordinate { anchorCoordinate(.height) }
40 |
41 | #if os(iOS) || os(tvOS)
42 | var leftMargin: MortarCoordinate { anchorCoordinate(.leftMargin) }
43 | var rightMargin: MortarCoordinate { anchorCoordinate(.rightMargin) }
44 | var topMargin: MortarCoordinate { anchorCoordinate(.topMargin) }
45 | var bottomMargin: MortarCoordinate { anchorCoordinate(.bottomMargin) }
46 | var leadingMargin: MortarCoordinate { anchorCoordinate(.leadingMargin) }
47 | var trailingMargin: MortarCoordinate { anchorCoordinate(.trailingMargin) }
48 | var centerXWithinMargins: MortarCoordinate { anchorCoordinate(.centerXWithinMargins) }
49 | var centerYWithinMargins: MortarCoordinate { anchorCoordinate(.centerYWithinMargins) }
50 | var sideMargins: MortarCoordinate { anchorCoordinate(.sideMargins) }
51 | var capMargins: MortarCoordinate { anchorCoordinate(.capMargins) }
52 | var edgeMargins: MortarCoordinate { anchorCoordinate(.edgeMargins) }
53 | #endif
54 |
55 | var sides: MortarCoordinate { anchorCoordinate(.sides) }
56 | var caps: MortarCoordinate { anchorCoordinate(.caps) }
57 | var topLeft: MortarCoordinate { anchorCoordinate(.topLeft) }
58 | var topLeading: MortarCoordinate { anchorCoordinate(.topLeading) }
59 | var topRight: MortarCoordinate { anchorCoordinate(.topRight) }
60 | var topTrailing: MortarCoordinate { anchorCoordinate(.topTrailing) }
61 | var bottomLeft: MortarCoordinate { anchorCoordinate(.bottomLeft) }
62 | var bottomLeading: MortarCoordinate { anchorCoordinate(.bottomLeading) }
63 | var bottomRight: MortarCoordinate { anchorCoordinate(.bottomRight) }
64 | var bottomTrailing: MortarCoordinate { anchorCoordinate(.bottomTrailing) }
65 | var edges: MortarCoordinate { anchorCoordinate(.edges) }
66 | var center: MortarCoordinate { anchorCoordinate(.center) }
67 | }
68 |
--------------------------------------------------------------------------------
/Mortar/MortarConstraint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarConstraint.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 |
8 | /// A `MortarConstraint` binds a target and source together with a relation (=, <=, >=).
9 | /// This constraint coordinates must represent non-virtual, single-sub-attribute
10 | /// values with the same theme (position, size, axis).
11 | public class MortarConstraint {
12 | let target: MortarCoordinate
13 | let source: MortarCoordinate
14 | let relation: MortarAliasLayoutRelation
15 |
16 | private let layoutConstraintBuilder: (() -> NSLayoutConstraint?)?
17 | private(set) lazy var layoutConstraint: NSLayoutConstraint? = layoutConstraintBuilder?()
18 |
19 | init(
20 | target: MortarCoordinate,
21 | source: MortarCoordinate,
22 | relation: MortarAliasLayoutRelation
23 | ) {
24 | self.target = target
25 | self.source = source
26 | self.relation = relation
27 |
28 | guard let targetItem = target.item as? MortarView else {
29 | MortarError.emit("Cannot create constraint for non-view target")
30 | self.layoutConstraintBuilder = nil
31 | return
32 | }
33 |
34 | guard
35 | let targetAttribute = target.attribute,
36 | let targetStandardAttribute = targetAttribute.standardLayoutAttribute
37 | else {
38 | MortarError.emit("Cannot create constraint without target attribute")
39 | self.layoutConstraintBuilder = nil
40 | return
41 | }
42 |
43 | self.layoutConstraintBuilder = {
44 | var resolvedSourceItem: Any?
45 | do {
46 | resolvedSourceItem = try resolveSourceItem(source.item)
47 | } catch {
48 | return nil
49 | }
50 |
51 | return NSLayoutConstraint(
52 | item: targetItem,
53 | attribute: targetStandardAttribute,
54 | relatedBy: relation,
55 | toItem: resolvedSourceItem,
56 | attribute: source.attribute?.standardLayoutAttribute ?? targetStandardAttribute,
57 | multiplier: source.multiplier,
58 | constant: source.constant.0
59 | )
60 | }
61 | }
62 | }
63 |
64 | private func resolveSourceItem(_ item: Any?) throws -> Any? {
65 | guard let item else {
66 | return nil
67 | }
68 |
69 | if let view = item as? MortarView {
70 | return view
71 | }
72 |
73 | if let guide = item as? MortarLayoutGuide {
74 | return guide
75 | }
76 |
77 | if let reference = item as? MortarRelativeAnchor {
78 | switch reference {
79 | case let .parent(view, block):
80 | guard let parent = view.superview else {
81 | MortarError.emit("Constraint source item must be a subview of a UIView")
82 | return nil
83 | }
84 | return block(parent)
85 | case let .reference(referenceId, block):
86 | guard let referenceView = MortarMainThreadLayoutStack.shared.viewForLayoutReference(id: referenceId) else {
87 | MortarError.emit("Could not resolve layout referenceId [\(referenceId)]")
88 | return nil
89 | }
90 | return block(referenceView)
91 | }
92 | }
93 |
94 | MortarError.emit("Invalid constraint source item")
95 | throw NSError(domain: "", code: 0, userInfo: nil)
96 | }
97 |
--------------------------------------------------------------------------------
/Mortar/MortarAxisPriorities.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarAxisPriorities.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | /// Represents the layout priorities for both horizontal and vertical axes.
7 | public struct MortarAxisPriorities {
8 | /// The horizontal layout priority.
9 | public var horizontal: MortarAliasLayoutPriority
10 |
11 | /// The vertical layout priority.
12 | public var vertical: MortarAliasLayoutPriority
13 |
14 | /// Initializes the priorities with specified horizontal and vertical values.
15 | /// - Parameters:
16 | /// - horizontal: The horizontal layout priority.
17 | /// - vertical: The vertical layout priority.
18 | public init(
19 | horizontal: MortarAliasLayoutPriority,
20 | vertical: MortarAliasLayoutPriority
21 | ) {
22 | self.horizontal = horizontal
23 | self.vertical = vertical
24 | }
25 | }
26 |
27 | public extension MortarView {
28 | /// Gets or sets the compression resistance priorities for both horizontal and vertical axes.
29 | var compressionResistance: MortarAxisPriorities {
30 | get {
31 | .init(
32 | horizontal: contentCompressionResistancePriority(for: .horizontal),
33 | vertical: contentCompressionResistancePriority(for: .vertical)
34 | )
35 | }
36 | set {
37 | setContentCompressionResistancePriority(newValue.horizontal, for: .horizontal)
38 | setContentCompressionResistancePriority(newValue.vertical, for: .vertical)
39 | }
40 | }
41 |
42 | /// Gets or sets the horizontal compression resistance priority.
43 | var compressionResistanceHorizontal: MortarAliasLayoutPriority {
44 | get {
45 | contentCompressionResistancePriority(for: .horizontal)
46 | }
47 | set {
48 | setContentCompressionResistancePriority(newValue, for: .horizontal)
49 | }
50 | }
51 |
52 | /// Gets or sets the vertical compression resistance priority.
53 | var compressionResistanceVertical: MortarAliasLayoutPriority {
54 | get {
55 | contentCompressionResistancePriority(for: .vertical)
56 | }
57 | set {
58 | setContentCompressionResistancePriority(newValue, for: .vertical)
59 | }
60 | }
61 |
62 | /// Gets or sets the content hugging priorities for both horizontal and vertical axes.
63 | var contentHugging: MortarAxisPriorities {
64 | get {
65 | .init(
66 | horizontal: contentHuggingPriority(for: .horizontal),
67 | vertical: contentHuggingPriority(for: .vertical)
68 | )
69 | }
70 | set {
71 | setContentHuggingPriority(newValue.horizontal, for: .horizontal)
72 | setContentHuggingPriority(newValue.vertical, for: .vertical)
73 | }
74 | }
75 |
76 | /// Gets or sets the horizontal content hugging priority.
77 | var contentHuggingHorizontal: MortarAliasLayoutPriority {
78 | get {
79 | contentHuggingPriority(for: .horizontal)
80 | }
81 | set {
82 | setContentHuggingPriority(newValue, for: .horizontal)
83 | }
84 | }
85 |
86 | /// Gets or sets the vertical content hugging priority.
87 | var contentHuggingVertical: MortarAliasLayoutPriority {
88 | get {
89 | contentHuggingPriority(for: .vertical)
90 | }
91 | set {
92 | setContentHuggingPriority(newValue, for: .vertical)
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/DemoPages/LayoutFeatures.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LayoutFeatures.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | import Mortar
7 |
8 | class LayoutFeaturesViewController: UIViewController {
9 | override func loadView() {
10 | view = UIContainer { container in
11 | container.backgroundColor = .lightGray
12 |
13 | // Basic layout features against named parent
14 | UIView {
15 | $0.backgroundColor = .blue
16 | $0.layout.top == container.layout.top
17 | $0.layout.leading == container.layout.leading
18 | $0.layout.size == CGSize(width: 100, height: 100)
19 | }
20 |
21 | // Use anonymous parent layout reference
22 | UIView {
23 | $0.backgroundColor = .red
24 | $0.layout.topTrailing == $0.parentLayout.topTrailing
25 | $0.layout.size == CGSize(width: 100, height: 100)
26 |
27 | // For use in the example below
28 | $0.layoutReferenceId = "redSquare"
29 | }
30 |
31 | // Use layout references to bind layout against
32 | // any other view in the hierarchy
33 | UIView {
34 | $0.backgroundColor = .yellow
35 | $0.layout.bottomTrailing == $0.referencedLayout("redSquare").bottomTrailing
36 | $0.layout.size == CGSize(width: 25, height: 25)
37 | }
38 |
39 | // Use a named UIView as the right side if the attribute
40 | // is the same
41 | UIView {
42 | $0.backgroundColor = .green
43 | $0.layout.bottomLeading == container // don't need to use layout anchors
44 | $0.layout.size == $0.referencedLayout("redSquare").size
45 | }
46 |
47 | // Apply operators to layout to adjust constraint constants
48 | UIView {
49 | $0.backgroundColor = .orange
50 | $0.layout.bottom == $0.parentLayout.bottom - 40
51 | $0.layout.trailing == $0.parentLayout.trailing - 40
52 | $0.layout.size == $0.referencedLayout("redSquare").size / 2
53 | }
54 |
55 | // Use inequality operators for less/greater than constraints
56 | UIView {
57 | $0.backgroundColor = .purple
58 | $0.layout.size >= $0.referencedLayout("redSquare").size
59 | $0.layout.size <= CGSize(width: 200, height: 200)
60 | $0.layout.top == $0.referencedLayout("redSquare").bottom + 20
61 | $0.layout.trailing == $0.parentLayout.trailing
62 | }
63 |
64 | // Use layout guides as well
65 | UIView {
66 | $0.backgroundColor = .brown
67 | $0.layout.bottom == container.safeAreaLayoutGuide.layout.bottom
68 | $0.layout.leading == $0.parentLayout.leading
69 | $0.layout.size == CGSize(width: 300, height: 20)
70 | }
71 |
72 | // Capture layout constraints and modify them
73 | UIButton(type: .roundedRect) {
74 | $0.backgroundColor = .yellow
75 | $0.setTitle("Move me", for: .normal)
76 | $0.layout.size == CGSize(width: 100, height: 44)
77 | let group = $0.layout.center == $0.parentLayout.center
78 | $0.handleEvents(.touchUpInside) { _ in
79 | // The group can contain multiple component constraints,
80 | // like 'center' capturing (centerX, centerY) -- in this example
81 | // modifying just the first will update centerX.
82 | group.layoutConstraints.first?.constant += 20
83 | }
84 | }
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Mortar/MortarStyle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarStyle.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 | import UIKit
8 | #else
9 | import AppKit
10 | #endif
11 |
12 | private var kStyleBoxAssociationKey = 0
13 |
14 | /// A class that wraps an `[NSAttributedString.Key: Any]` for use with associated
15 | /// objects and allowing the dictionary to be Sendable.
16 | public final class TextStyle: @unchecked Sendable {
17 | public let attributes: [NSAttributedString.Key: Any]
18 |
19 | public init(_ attributeDictionary: [NSAttributedString.Key: Any]) {
20 | self.attributes = attributeDictionary
21 | }
22 | }
23 |
24 | /// A protocol that defines text styling capabilities for views.
25 | ///
26 | /// Conforming types can use the `textStyle` property to get and set
27 | /// text attributes that will be applied to their content.
28 | @MainActor public protocol MortarTextStylable: MortarView {
29 | var textStyle: TextStyle? { get set }
30 | }
31 |
32 | /// A public extension to `MortarTextStylable` that implements the `textStyle` property.
33 | public extension MortarTextStylable {
34 | /// The text style attributes to apply to the view's content.
35 | var textStyle: TextStyle? {
36 | get {
37 | objc_getAssociatedObject(self, &kStyleBoxAssociationKey) as? TextStyle
38 | }
39 | set {
40 | objc_setAssociatedObject(self, &kStyleBoxAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
41 | }
42 | }
43 | }
44 |
45 | public extension NSParagraphStyle {
46 | /// Allows the user to configure a paragraph style inline, e.g.
47 | ///
48 | /// let style: NSParagraphStyle = .configure {
49 | /// $0.alignment = .center
50 | /// }
51 | static func configure(_ block: (NSMutableParagraphStyle) -> Void) -> NSMutableParagraphStyle {
52 | let style = NSMutableParagraphStyle()
53 | block(style)
54 | return style
55 | }
56 | }
57 |
58 | #if os(iOS) || os(tvOS)
59 |
60 | /// Extension to `UILabel` that makes it conform to `MortarTextStylable`.
61 | ///
62 | /// This extension adds support for styled text in UILabel instances,
63 | /// allowing them to use the `styledText` property to set attributed text.
64 | extension UILabel: MortarTextStylable {
65 | /// The styled text content of the label.
66 | ///
67 | /// When getting, returns the current text value.
68 | /// When setting, applies the text with the current `textStyle` attributes
69 | /// to create an attributed string.
70 | public var styledText: String? {
71 | get {
72 | text
73 | }
74 |
75 | set {
76 | attributedText = newValue.flatMap {
77 | NSAttributedString(string: $0, attributes: textStyle?.attributes)
78 | }
79 | }
80 | }
81 | }
82 |
83 | /// Extension to `UIButton` that makes it conform to `MortarTextStylable`.
84 | ///
85 | /// This extension adds support for styled text in UIButton instances,
86 | /// allowing them to use the `setStyledTitle` method to set attributed titles.
87 | extension UIButton: MortarTextStylable {
88 | /// Sets the styled title for a button state.
89 | ///
90 | /// This method applies the current `textStyle` attributes to the provided title
91 | /// and sets it as the attributed title for the specified control state.
92 | ///
93 | /// - Parameters:
94 | /// - title: The title string to apply styling to.
95 | /// - state: The control state for which to set the title.
96 | public func setStyledTitle(_ title: String?, for state: UIControl.State) {
97 | setAttributedTitle(
98 | title.flatMap {
99 | NSAttributedString(string: $0, attributes: textStyle?.attributes)
100 | },
101 | for: state
102 | )
103 | }
104 | }
105 |
106 | #endif
107 |
--------------------------------------------------------------------------------
/Mortar/MortarConstraintGroup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarConstraintGroup.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | /// A `MortarConstraintGroup` is a collection of `MortarConstraint` objects bound by
7 | /// a single virtual expression.
8 | public struct MortarConstraintGroup {
9 | let constraints: [MortarConstraint]
10 |
11 | init(constraints: [MortarConstraint]) {
12 | self.constraints = constraints
13 | }
14 |
15 | init(
16 | target: MortarCoordinate,
17 | source: MortarCoordinate,
18 | relation: MortarAliasLayoutRelation
19 | ) {
20 | guard let targetItem = target.item else {
21 | MortarError.emit("Creating a MortarConstraintGroup requires a target item")
22 | self.constraints = []
23 | return
24 | }
25 |
26 | guard let targetAttribute = target.attribute else {
27 | MortarError.emit("Creating a MortarConstraintGroup requires a target attribute")
28 | self.constraints = []
29 | return
30 | }
31 |
32 | guard source.attribute == nil || source.attribute?.degree == targetAttribute.degree else {
33 | MortarError.emit("If a source attribute is explicitly provided, it must have the same degree as the target attribute")
34 | self.constraints = []
35 | return
36 | }
37 |
38 | // Construct component constraint array
39 | if targetAttribute.degree == 1 {
40 | self.constraints = [
41 | MortarConstraint(target: target, source: source, relation: relation),
42 | ]
43 | } else {
44 | // Decompose the multiple components into separate expressions
45 | self.constraints = (0 ..< targetAttribute.degree).map { index -> MortarConstraint in
46 | .init(
47 | target: .init(
48 | item: target.item,
49 | attribute: .from(targetAttribute.componentAttributes[index]),
50 | multiplier: target.multiplier,
51 | constant: (tupleIndex(target.constant, index), 0, 0, 0),
52 | priority: target.priority
53 | ),
54 | source: .init(
55 | item: source.item,
56 | attribute: source.attribute.flatMap { .from($0.componentAttributes[index]) },
57 | multiplier: source.multiplier,
58 | constant: (tupleIndex(source.constant, index), 0, 0, 0),
59 | priority: source.priority
60 | ),
61 | relation: relation
62 | )
63 | }
64 | }
65 |
66 | // Turn off target `translatesAutoresizingMaskIntoConstraints` if it is a view
67 | if let targetView = targetItem as? MortarView {
68 | targetView.translatesAutoresizingMaskIntoConstraints = false
69 | }
70 |
71 | // We either activate the constraint now, or wait until the processing stack is complete
72 | if MortarMainThreadLayoutStack.shared.insideStack() {
73 | MortarMainThreadLayoutStack.shared.accumulate(constraints: constraints)
74 | } else {
75 | for constraint in constraints {
76 | constraint.layoutConstraint?.isActive = constraint.source.startActivated
77 | }
78 | }
79 | }
80 |
81 | /// Returns the underlying NSLayoutConstraints associated with this group.
82 | /// Note that the constraints will not be activated until the Mortar
83 | /// declaration stack has been completely popped.
84 | public var layoutConstraints: [NSLayoutConstraint] {
85 | constraints.compactMap(\.layoutConstraint)
86 | }
87 |
88 | /// Modifies/returns the `isActive` state of the constraint group. For the
89 | /// retrieval, only returns true if all member-constraints are true.
90 | public var isActive: Bool {
91 | get {
92 | constraints.allSatisfy { $0.layoutConstraint?.isActive ?? false }
93 | }
94 | set {
95 | for constraint in constraints {
96 | constraint.layoutConstraint?.isActive = newValue
97 | }
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/MortarTests/MortarLayoutTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarLayoutTests.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | @testable import Mortar
7 | import XCTest
8 |
9 | class MortarLayoutTests: XCTestCase {
10 | var testContainer: MortarView!
11 |
12 | override func setUp() {
13 | super.setUp()
14 | testContainer = MortarView(frame: .init(x: 200, y: 200, width: 1000, height: 1000))
15 | }
16 |
17 | override func tearDown() {
18 | super.tearDown()
19 | }
20 |
21 | func testEdgesConstraint() {
22 | var testView: MortarView!
23 |
24 | testContainer.configure {
25 | UILabel {
26 | $0.layout.edges == $0.parentLayout.edges
27 | testView = $0
28 | }
29 | }
30 |
31 | testContainer.layoutIfNeeded()
32 | XCTAssertEqual(testView.frame, CGRect(x: 0, y: 0, width: 1000, height: 1000))
33 | }
34 |
35 | func testTopConstraint() {
36 | var testView: MortarView!
37 |
38 | testContainer.configure {
39 | UILabel {
40 | $0.layout.top == $0.parentLayout.top + 50
41 | $0.layout.bottom == $0.parentLayout.bottom
42 | testView = $0
43 | }
44 | }
45 |
46 | testContainer.layoutIfNeeded()
47 | XCTAssertEqual(testView.frame.origin.y, 50)
48 | XCTAssertEqual(testView.frame.size.height, 950)
49 | }
50 |
51 | func testBottomConstraint() {
52 | var testView: MortarView!
53 |
54 | testContainer.configure {
55 | UILabel {
56 | $0.layout.height == 100
57 | $0.layout.bottom == $0.parentLayout.bottom
58 | testView = $0
59 | }
60 | }
61 |
62 | testContainer.layoutIfNeeded()
63 | XCTAssertEqual(testView.frame.height, 100)
64 | XCTAssertEqual(testView.frame.origin.y, 900)
65 | }
66 |
67 | func testLeadingConstraint() {
68 | var testView: MortarView!
69 |
70 | testContainer.configure {
71 | UILabel {
72 | $0.layout.leading == $0.parentLayout.leading
73 | testView = $0
74 | }
75 | }
76 |
77 | testContainer.layoutIfNeeded()
78 | XCTAssertEqual(testView.frame.origin.x, 0)
79 | }
80 |
81 | func testTrailingConstraint() {
82 | var testView: MortarView!
83 |
84 | testContainer.configure {
85 | UILabel {
86 | $0.layout.leading == $0.parentLayout.leading
87 | $0.layout.trailing == $0.parentLayout.trailing
88 | testView = $0
89 | }
90 | }
91 |
92 | testContainer.layoutIfNeeded()
93 | XCTAssertEqual(testView.frame.origin.x, 1000 - testView.frame.width)
94 | XCTAssertEqual(testView.frame.size.width, 1000)
95 | }
96 |
97 | func testCenterXConstraint() {
98 | var testView: MortarView!
99 |
100 | testContainer.configure {
101 | UILabel {
102 | $0.layout.centerX == $0.parentLayout.centerX
103 | $0.layout.width == 500
104 | testView = $0
105 | }
106 | }
107 |
108 | testContainer.layoutIfNeeded()
109 | XCTAssertEqual(testView.center.x, 500)
110 | XCTAssertEqual(testView.frame.origin.x, 250)
111 | XCTAssertEqual(testView.bounds.size.width, 500)
112 | }
113 |
114 | func testCenterYConstraint() {
115 | var testView: MortarView!
116 |
117 | testContainer.configure {
118 | UILabel {
119 | $0.layout.centerY == $0.parentLayout.centerY
120 | $0.layout.height == 500
121 | testView = $0
122 | }
123 | }
124 |
125 | testContainer.layoutIfNeeded()
126 | XCTAssertEqual(testView.center.y, 500)
127 | XCTAssertEqual(testView.frame.origin.y, 250)
128 | XCTAssertEqual(testView.bounds.size.height, 500)
129 | }
130 |
131 | func testWidthConstraint() {
132 | var testView: MortarView!
133 |
134 | testContainer.configure {
135 | UILabel {
136 | $0.layout.width == 200
137 | testView = $0
138 | }
139 | }
140 |
141 | testContainer.layoutIfNeeded()
142 | XCTAssertEqual(testView.frame.width, 200)
143 | }
144 |
145 | func testHeightConstraint() {
146 | var testView: MortarView!
147 |
148 | testContainer.configure {
149 | UILabel {
150 | $0.layout.height == 300
151 | testView = $0
152 | }
153 | }
154 |
155 | testContainer.layoutIfNeeded()
156 | XCTAssertEqual(testView.frame.height, 300)
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/DemoPages/ReactiveFeatures.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReactiveFeatures.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 | import Mortar
8 |
9 | /// Mortar is designed with CombineEx in mind for view state/handling.
10 | class ReactiveFeaturesViewController: UIViewController {
11 | /// Putting underlying state/model in a separate object helps
12 | /// enforce unidirectional flow of information, and makes view
13 | /// controller state easier to unit test.
14 | private let model = ReactiveFeaturesViewControllerModel()
15 |
16 | override func loadView() {
17 | view = UIContainer { container in
18 | container.backgroundColor = .lightGray
19 |
20 | UIVStack {
21 | $0.backgroundColor = .white
22 | $0.alignment = .fill
23 | $0.layout.sides == $0.parentLayout.sideMargins
24 | $0.layout.centerY == $0.parentLayout.centerY
25 |
26 | UISwitch {
27 | // Many variations of `handleEvents` allow you to reactively
28 | // handle typical UIControl actions. This example allows you to
29 | // pass an input in an Action based on the transform block
30 | $0.handleEvents(.valueChanged, model.toggleStateAction) { $0.isOn }
31 | }
32 |
33 | UILabel {
34 | $0.layout.height == 44
35 | // Bind is the preferred method to assign reactive properties
36 | // to single-value keypaths.
37 | $0.bind(\.text) <~ model.toggleState.map { "Toggle is \($0)" }
38 | }
39 |
40 | UILabel {
41 | $0.layout.height == 44
42 | $0.bind(\.text) <~ model.toggleCount.map { "Toggle change count: \($0)" }
43 | }
44 | }
45 |
46 | UIView {
47 | $0.backgroundColor = .orange
48 | $0.layout.size == CGSize(width: 40, height: 40)
49 | $0.layout.centerX == $0.parentLayout.centerX
50 | $0.layout.top == $0.parentLayout.top + 200
51 |
52 | UILabel {
53 | $0.layout.center == $0.parentLayout.center
54 | $0.bind(\.text) <~ model.toggleCount.map(\.description)
55 | }
56 |
57 | // You can also sink publishers when you want to perform more
58 | // complex tasks on their value update.
59 | $0.sink(model.toggled) { square in
60 | UIView.animate(
61 | springDuration: 0.4,
62 | bounce: 0.4,
63 | initialSpringVelocity: 0,
64 | delay: 0,
65 | options: [],
66 | animations: {
67 | square.transform = CGAffineTransform(
68 | translationX: CGFloat.random(in: -50 ... 50),
69 | y: CGFloat.random(in: -50 ... 50)
70 | )
71 | }, completion: { _ in }
72 | )
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | // MARK: - View Controller Model
80 |
81 | private class ReactiveFeaturesViewControllerModel {
82 | // Public interface
83 |
84 | /// For UI hookup, consider exposing your work publishers as Actions, which
85 | /// ensures that the underlying work can only be run once in parallel. Each
86 | /// time the action is triggered, it will build the publisher from the
87 | /// block declared at initialization.
88 | private(set) lazy var toggleStateAction = Action { [weak self] newValue in
89 | self?.toggleState(to: newValue) ?? .empty()
90 | }
91 |
92 | /// Expose internal MutableProperty instances as Property; this allows
93 | /// consumers to monitor state without the ability to modify it.
94 | private(set) lazy var toggleState = Property(mutableToggleState)
95 | private(set) lazy var toggleCount = Property(mutableToggleCount)
96 | private(set) lazy var toggled = toggleState.dropFirst().map { _ in }.eraseToAnyPublisher()
97 |
98 | // Private implementation
99 |
100 | private let mutableToggleState = MutableProperty(false)
101 | private let mutableToggleCount = MutableProperty(0)
102 |
103 | /// This is an example of generating a deferred publisher that can perform some
104 | /// async work on each subscription.
105 | private func toggleState(to newValue: Bool) -> AnyDeferredPublisher {
106 | DeferredFuture { [weak self] promise in
107 | self?.mutableToggleState.value = newValue
108 | self?.mutableToggleCount.modify { $0 += 1 }
109 | promise(.success(()))
110 | }.eraseToAnyDeferredPublisher()
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/DemoPages/BasicManagedTableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BasicManagedTableView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 | import Mortar
8 |
9 | /// Each row in a ManagedTableView is a pair of model/cell implementations
10 | /// that refer to eachother with associated types.
11 | ///
12 | /// The model is an immutable struct, which can be composed in background
13 | /// threads from other reactive sources.
14 | ///
15 | /// The models are composed into sections, which are reactively fed into
16 | /// the ManagedTableView. Interally, the table dequeues associated cell
17 | /// types and feeds the corresponding model into the cell.
18 | ///
19 | /// This example shows some very basic model/cell construction.
20 | class BasicManagedTableViewController: UIViewController {
21 | override func loadView() {
22 | view = UIContainer {
23 | $0.backgroundColor = .white
24 |
25 | ManagedTableView {
26 | $0.layout.edges == $0.parentLayout.edges
27 | $0.bind(\.sections) <~ Property(value: [self.makeSection()])
28 | }
29 | }
30 | }
31 |
32 | /// Constructing the section models is usually more complex than you
33 | /// want in the view hierarchy code. Feel free to create a separate
34 | /// builder function, like this. Typically you would supply reactive
35 | /// inputs.
36 | private func makeSection() -> ManagedTableViewSection {
37 | // Sections/row models can all be created from structs
38 | ManagedTableViewSection(
39 | rows: [
40 | SimpleTextRowCell.Model(text: "Simple row, disclosure false", showDisclosure: false),
41 | SimpleTextRowCell.Model(text: "Simple row, disclosure true", showDisclosure: true),
42 |
43 | // You can freely mix and match models inside the rows array
44 |
45 | AlertTextRowCell.Model(text: "Tap to alert") { [weak self] in
46 | let alert = UIAlertController(title: "Alert", message: "Message", preferredStyle: .alert)
47 | alert.addAction(.init(title: "OK", style: .default))
48 | self?.present(alert, animated: true)
49 | },
50 | ]
51 | )
52 | }
53 | }
54 |
55 | // MARK: - Row Implementations
56 |
57 | /// For managed cells, override the standard init method and configure
58 | /// the `contentView` with your view hierarchy.
59 | ///
60 | /// The `model` ivar is provided for you. It is an AnyPublisher
61 | /// that will emit a new Model value each time the section input changes.
62 | /// Note that this also crosses cell reuse - so the new model can represent
63 | /// a completely different underlying element.
64 | private final class SimpleTextRowCell: UITableViewCell, ManagedTableViewCell {
65 | /// Models can be simple structs; don't forget to declare conformity
66 | /// to `ManagedTableViewCellModel`
67 | struct Model: ManagedTableViewCellModel, ArbitrarilyIdentifiable {
68 | typealias Cell = SimpleTextRowCell
69 |
70 | let id: String = UUID().uuidString
71 | let text: String
72 | let showDisclosure: Bool
73 | }
74 |
75 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
76 | super.init(style: style, reuseIdentifier: reuseIdentifier)
77 |
78 | contentView.configure {
79 | UILabel {
80 | $0.layout.leading == $0.parentLayout.leadingMargin
81 | $0.layout.caps == $0.parentLayout.caps
82 | $0.layout.height >= 50
83 | $0.textColor = .darkGray
84 |
85 | // Note that because the model itself is a publisher, you will
86 | // often need to map it (instead of a simple `model.text` here)
87 | $0.bind(\.text) <~ model.map(\.text)
88 | }
89 | }
90 |
91 | // This is a standard UITableViewCell, so feel free to adjust other
92 | // aspects of its behavior as needed
93 | bind(\.accessoryType) <~ model.map { $0.showDisclosure ? .disclosureIndicator : .none }
94 | }
95 |
96 | @available(*, unavailable)
97 | required init?(coder: NSCoder) {
98 | fatalError("init(coder:) has not been implemented")
99 | }
100 | }
101 |
102 | private final class AlertTextRowCell: UITableViewCell, ManagedTableViewCell {
103 | struct Model: ManagedTableViewCellModel, ArbitrarilyIdentifiable {
104 | typealias Cell = AlertTextRowCell
105 |
106 | let id: String = UUID().uuidString
107 | let text: String
108 | let onSelect: ((ManagedTableView, IndexPath) -> Void)?
109 | }
110 |
111 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
112 | super.init(style: style, reuseIdentifier: reuseIdentifier)
113 |
114 | textLabel?.bind(\.text) <~ model.map(\.text)
115 | }
116 |
117 | @available(*, unavailable)
118 | required init?(coder: NSCoder) {
119 | fatalError("init(coder:) has not been implemented")
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/Mortar/MortarReactive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarReactive.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import CombineEx
7 |
8 | // MARK: - UIControl Publish Actions
9 |
10 | #if os(iOS) || os(tvOS)
11 |
12 | private class TargetBox {
13 | let subject: PassthroughSubject = .init()
14 | @objc func invoke(sender: UIControl) {
15 | if let control = sender as? UIControlSubtype {
16 | subject.send(control)
17 | }
18 | }
19 | }
20 |
21 | public protocol _MortarUIControlEventsProviding: UIControl {}
22 |
23 | public extension _MortarUIControlEventsProviding {
24 | func publishEvents(
25 | _ filter: UIControl.Event = [.allEvents]
26 | ) -> AnyPublisher {
27 | let internalTarget = TargetBox()
28 | permanentlyAssociate(internalTarget)
29 | addTarget(internalTarget, action: #selector(TargetBox.invoke(sender:)), for: filter)
30 | return internalTarget.subject.eraseToAnyPublisher()
31 | }
32 |
33 | func handleEvents(
34 | _ filter: UIControl.Event,
35 | _ handleBlock: @escaping @Sendable (Self) -> Void
36 | ) {
37 | publishEvents(filter)
38 | .sink(
39 | duringLifetimeOf: self,
40 | receiveValue: handleBlock
41 | )
42 | }
43 |
44 | func handleEvents(
45 | _ filter: UIControl.Event,
46 | _ actionTrigger: some ActionTriggerConvertible
47 | ) {
48 | let resolvedActionTrigger = actionTrigger.asActionTrigger
49 | publishEvents(filter)
50 | .sink(
51 | duringLifetimeOf: self,
52 | receiveValue: { _ in
53 | resolvedActionTrigger
54 | .applyAnonymous(self)
55 | .sink(duringLifetimeOf: self)
56 | }
57 | )
58 | }
59 |
60 | func handleEvents(
61 | _ filter: UIControl.Event,
62 | _ actionTrigger: some ActionTriggerConvertible
63 | ) {
64 | let resolvedActionTrigger = actionTrigger.asActionTrigger
65 | publishEvents(filter)
66 | .sink(
67 | duringLifetimeOf: self,
68 | receiveValue: { _ in
69 | resolvedActionTrigger
70 | .applyAnonymous(())
71 | .sink(duringLifetimeOf: self)
72 | }
73 | )
74 | }
75 |
76 | func handleEvents(
77 | _ filter: UIControl.Event,
78 | _ actionTriggerPublisher: some Publisher, Never>
79 | ) {
80 | let currentTrigger = Property?>(
81 | initial: nil,
82 | then: actionTriggerPublisher.map(\.asActionTrigger)
83 | )
84 |
85 | publishEvents(filter)
86 | .sink(
87 | duringLifetimeOf: self,
88 | receiveValue: { _ in
89 | currentTrigger.value?
90 | .applyAnonymous(self)
91 | .sink(duringLifetimeOf: self)
92 | }
93 | )
94 | }
95 |
96 | func handleEvents(
97 | _ filter: UIControl.Event,
98 | _ actionTriggerPublisher: some Publisher, Never>
99 | ) {
100 | let currentTrigger = Property?>(
101 | initial: nil,
102 | then: actionTriggerPublisher.map(\.asActionTrigger)
103 | )
104 |
105 | publishEvents(filter)
106 | .sink(
107 | duringLifetimeOf: self,
108 | receiveValue: { _ in
109 | currentTrigger.value?
110 | .applyAnonymous(())
111 | .sink(duringLifetimeOf: self)
112 | }
113 | )
114 | }
115 |
116 | func handleEvents(
117 | _ filter: UIControl.Event,
118 | _ actionTrigger: some ActionTriggerConvertible,
119 | _ transform: @escaping (Self) -> Input
120 | ) {
121 | let resolvedActionTrigger = actionTrigger.asActionTrigger
122 | publishEvents(filter)
123 | .sink(
124 | duringLifetimeOf: self,
125 | receiveValue: { _ in
126 | resolvedActionTrigger
127 | .applyAnonymous(transform(self))
128 | .sink(duringLifetimeOf: self)
129 | }
130 | )
131 | }
132 |
133 | func handleEvents(
134 | _ filter: UIControl.Event,
135 | _ actionTriggerPublisher: some Publisher, Never>,
136 | _ transform: @escaping (Self) -> Input
137 | ) {
138 | let currentTrigger = Property?>(
139 | initial: nil,
140 | then: actionTriggerPublisher.map(\.asActionTrigger)
141 | )
142 |
143 | publishEvents(filter)
144 | .sink(
145 | duringLifetimeOf: self,
146 | receiveValue: { _ in
147 | currentTrigger.value?
148 | .applyAnonymous(transform(self))
149 | .sink(duringLifetimeOf: self)
150 | }
151 | )
152 | }
153 | }
154 |
155 | extension UIControl: _MortarUIControlEventsProviding {}
156 |
157 | #endif
158 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/DemoPages/OriginalViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OriginalViewController.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | import Mortar
7 |
8 | class OriginalViewController: UIViewController {
9 | let testProp = MutableProperty(0)
10 | let boolProp = MutableProperty(false)
11 |
12 | let tableStrings = MutableProperty<[String]>(["Hello", "World"])
13 |
14 | let legacyLabel = {
15 | let label = UILabel()
16 | label.text = "Old Style"
17 | label.backgroundColor = .magenta
18 | return label
19 | }()
20 |
21 | let touchAction = Deferred {
22 | NSLog("test")
23 | return Just(()).eraseToAnyPublisher()
24 | }.eraseToAnyDeferredPublisher()
25 |
26 | override func loadView() {
27 | view = UIContainer {
28 | $0.backgroundColor = .darkGray
29 |
30 | UIVStack {
31 | $0.backgroundColor = .lightGray
32 | $0.layout.sides == $0.parentLayout.sideMargins
33 | $0.layout.centerY == $0.parentLayout.centerY
34 |
35 | UILabel {
36 | $0.layout.height == 44
37 | $0.text = "Hello, World!"
38 | $0.textColor = .red
39 | $0.textAlignment = .center
40 | }
41 |
42 | UIButton(type: .roundedRect) {
43 | $0.layoutReferenceId = "button"
44 | $0.setTitle("Button", for: .normal)
45 | $0.handleEvents(.touchUpInside) { _ in NSLog("touched") }
46 | }
47 | }
48 | }
49 | let _ = UIContainer { parent in
50 | UIContainer { child1 in
51 | child1.backgroundColor = .blue
52 | child1.layout.center == parent.layout.center
53 | child1.layout.size == CGSize(width: 100, height: 200)
54 |
55 | UIView { inner in
56 | inner.backgroundColor = .yellow
57 | inner.layout.topLeft == parent.layout.topLeft + CGPoint(x: 40, y: 80)
58 | inner.layout.width == 100
59 | inner.layout.height == 200
60 | }
61 |
62 | legacyLabel.configure {
63 | $0.layout.centerX == parent.layout.centerX
64 | $0.layout.bottom == child1.layout.top
65 | }
66 | }
67 |
68 | ManagedTableView {
69 | $0.backgroundColor = .green
70 | $0.layout.leading == parent.layout.leading
71 | $0.layout.size == CGSize(width: 150, height: 300)
72 | $0.layout.top == parent.layout.top + 300
73 |
74 | $0.bind(\.sections) <~ tableStrings.map { strings -> [ManagedTableViewSection] in
75 | [
76 | ManagedTableViewSection(
77 | rows: strings.map {
78 | SimpleRowCell.Model(text: $0)
79 | }
80 | ),
81 | ]
82 | }
83 | }
84 |
85 | UIVStack {
86 | // Layout
87 | $0.layout.bottom == parent.safeAreaLayoutGuide.layout.bottom - 40
88 | $0.layout.sides == parent.layout.sides
89 |
90 | // Configuration
91 | $0.alignment = .center
92 | $0.backgroundColor = .white
93 |
94 | // Subviews
95 | UILabel {
96 | $0.text = "Hello, World!"
97 | }
98 |
99 | UILabel {
100 | $0.bind(\.text) <~ testProp.map { "Test \($0)" }
101 | }
102 |
103 | UILabel {
104 | $0.sink(testProp) { $0.text = "Test \($1)" }
105 | }
106 |
107 | UILabel {
108 | $0.sink(boolProp) { $0.text = "boolprop is \($1)" }
109 | }
110 |
111 | UILabel {
112 | $0.bind(\.text) <~ boolProp.map { "boolprop is \($0)" }
113 | }
114 |
115 | UISwitch {
116 | boolProp <~ $0.publishEvents(.valueChanged).map(\.isOn)
117 |
118 | $0.handleEvents(.valueChanged) { [weak self] _ in
119 | self?.tableStrings.modify { $0 = $0 + ["Toggled"] }
120 | }
121 | }
122 | }
123 | }
124 |
125 | for t in 1 ..< 100 {
126 | DispatchQueue.global().asyncAfter(deadline: .now() + TimeInterval(t)) {
127 | self.testProp.value = t
128 | }
129 | }
130 | }
131 | }
132 |
133 | class SimpleRowCell: UITableViewCell, ManagedTableViewCell {
134 | struct Model: ManagedTableViewCellModel, ArbitrarilyIdentifiable {
135 | typealias Cell = SimpleRowCell
136 |
137 | let id: String = UUID().uuidString
138 | let text: String
139 | }
140 |
141 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
142 | super.init(style: style, reuseIdentifier: reuseIdentifier)
143 |
144 | contentView.configure { contentView in
145 | UILabel {
146 | $0.layout.edges == contentView.layout.edges
147 | $0.bind(\.text) <~ self.model.map(\.text)
148 | }
149 | }
150 | }
151 |
152 | @available(*, unavailable)
153 | required init?(coder: NSCoder) {
154 | fatalError("init(coder:) has not been implemented")
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/ManagedTableViewDelegation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedTableViewDelegation.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public extension ManagedTableView {
11 | /// Configures the gesture recognizer's delegate with a block.
12 | ///
13 | /// - Parameter configureBlock: A closure that configures the gesture recognizer delegate.
14 | /// - Returns: The configured gesture recognizer instance.
15 | func scrollDelegation(_ configureBlock: (_ManagedTableViewScrollDelegateHandler) -> Void) {
16 | configureBlock(scrollDelegateHandler)
17 | }
18 | }
19 |
20 | public final class _ManagedTableViewScrollDelegateHandler: NSObject {
21 | /// Called when the user scrolls the content view.
22 | public var didScroll: ((ManagedTableView) -> Void)?
23 |
24 | /// Called when the user scrolls to the top of the content view.
25 | public var didScrollToTop: ((ManagedTableView) -> Void)?
26 |
27 | /// Asks the delegate if the scroll view should scroll to the top.
28 | public var shouldScrollToTop: ((ManagedTableView) -> Bool)?
29 |
30 | /// Called when the user finishes zooming.
31 | public var didZoom: ((ManagedTableView) -> Void)?
32 |
33 | /// Called when the user begins zooming.
34 | public var willBeginZooming: ((ManagedTableView, UIView?) -> Void)?
35 |
36 | /// Called when the user finishes zooming.
37 | public var didEndZooming: ((ManagedTableView, UIView?, CGFloat) -> Void)?
38 |
39 | /// Called when the user begins dragging.
40 | public var willBeginDragging: ((ManagedTableView) -> Void)?
41 |
42 | /// Called when the user ends dragging.
43 | public var willEndDragging: ((ManagedTableView, CGPoint, UnsafeMutablePointer) -> Void)?
44 |
45 | /// Called when the user ends dragging.
46 | public var didEndDragging: ((ManagedTableView, Bool) -> Void)?
47 |
48 | /// Called when the user begins decelerating.
49 | public var willBeginDecelerating: ((ManagedTableView) -> Void)?
50 |
51 | /// Called when the user ends decelerating.
52 | public var didEndDecelerating: ((ManagedTableView) -> Void)?
53 |
54 | /// Called when the scroll view ends scrolling animation.
55 | public var didEndScrollingAnimation: ((ManagedTableView) -> Void)?
56 |
57 | /// Called on any delegate method that signals end of a scroll
58 | public var didFinishAnyScrolling: ((ManagedTableView) -> Void)?
59 |
60 | /// Called when the scroll view's adjusted content inset changes.
61 | public var didChangeAdjustedContentInset: ((ManagedTableView) -> Void)?
62 | }
63 |
64 | extension ManagedTableView: UIScrollViewDelegate {
65 | public func scrollViewDidScroll(_ scrollView: UIScrollView) {
66 | scrollDelegateHandler.didScroll?(scrollView as! ManagedTableView)
67 | }
68 |
69 | public func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
70 | scrollDelegateHandler.didScrollToTop?(scrollView as! ManagedTableView)
71 | scrollDelegateHandler.didFinishAnyScrolling?(scrollView as! ManagedTableView)
72 | }
73 |
74 | public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
75 | scrollDelegateHandler.shouldScrollToTop?(scrollView as! ManagedTableView) ?? true
76 | }
77 |
78 | public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
79 | scrollDelegateHandler.willBeginZooming?(scrollView as! ManagedTableView, view)
80 | }
81 |
82 | public func scrollViewDidZoom(_ scrollView: UIScrollView) {
83 | scrollDelegateHandler.didZoom?(scrollView as! ManagedTableView)
84 | }
85 |
86 | public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
87 | scrollDelegateHandler.didEndZooming?(scrollView as! ManagedTableView, view, scale)
88 | }
89 |
90 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
91 | scrollDelegateHandler.willBeginDragging?(scrollView as! ManagedTableView)
92 | }
93 |
94 | public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {
95 | scrollDelegateHandler.willEndDragging?(scrollView as! ManagedTableView, velocity, targetContentOffset)
96 | }
97 |
98 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
99 | scrollDelegateHandler.didEndDragging?(scrollView as! ManagedTableView, decelerate)
100 | if !decelerate {
101 | scrollDelegateHandler.didFinishAnyScrolling?(scrollView as! ManagedTableView)
102 | }
103 | }
104 |
105 | public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
106 | scrollDelegateHandler.willBeginDecelerating?(scrollView as! ManagedTableView)
107 | }
108 |
109 | public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
110 | scrollDelegateHandler.didEndDecelerating?(scrollView as! ManagedTableView)
111 | scrollDelegateHandler.didFinishAnyScrolling?(scrollView as! ManagedTableView)
112 | }
113 |
114 | public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
115 | scrollDelegateHandler.didEndScrollingAnimation?(scrollView as! ManagedTableView)
116 | scrollDelegateHandler.didFinishAnyScrolling?(scrollView as! ManagedTableView)
117 | }
118 |
119 | public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
120 | scrollDelegateHandler.didChangeAdjustedContentInset?(scrollView as! ManagedTableView)
121 | }
122 | }
123 |
124 | #endif
125 |
--------------------------------------------------------------------------------
/Mortar/View+ResultBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+ResultBuilder.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | public struct MortarAddViewBox {
7 | /// The view to be added.
8 | let view: MortarView?
9 | }
10 |
11 | @resultBuilder
12 | public struct MortarAddSubviewsBuilder {
13 | /// Builds an expression from a `MortarView?`.
14 | /// - Parameter expression: The view to be added.
15 | /// - Returns: A `MortarAddViewBox` containing the provided view.
16 | public static func buildExpression(_ expression: MortarView?) -> MortarAddViewBox {
17 | MortarAddViewBox(view: expression)
18 | }
19 |
20 | /// Builds an expression from a `Void`.
21 | /// - Parameter expression: A void value.
22 | /// - Returns: A `MortarAddViewBox` with a nil view.
23 | public static func buildExpression(_ expression: Void) -> MortarAddViewBox {
24 | MortarAddViewBox(view: nil)
25 | }
26 |
27 | /// Builds an expression from a `MortarConstraintGroup`.
28 | /// - Parameter expression: A MortarConstraintGroup value.
29 | /// - Returns: A `MortarAddViewBox` with a nil view.
30 | public static func buildExpression(_ expression: MortarConstraintGroup) -> MortarAddViewBox {
31 | MortarAddViewBox(view: nil)
32 | }
33 |
34 | /// Builds an expression from a `AnyCancellable`.
35 | /// - Parameter expression: An AnyCancellable value.
36 | /// - Returns: A `MortarAddViewBox` with a nil view.
37 | public static func buildExpression(_ expression: AnyCancellable) -> MortarAddViewBox {
38 | MortarAddViewBox(view: nil)
39 | }
40 |
41 | /// Builds a block from multiple `MortarAddViewBox` components.
42 | /// - Parameter components: An array of `MortarAddViewBox` components.
43 | /// - Returns: An array of `MortarAddViewBox` components.
44 | public static func buildBlock(_ components: MortarAddViewBox...) -> [MortarAddViewBox] {
45 | components
46 | }
47 | }
48 |
49 | private extension MortarView {
50 | /// Processes an array of `MortarAddViewBox` instances and adds their views to the current view.
51 | /// - Parameter addViewBoxes: An array of `MortarAddViewBox` instances.
52 | func process(_ addViewBoxes: [MortarAddViewBox]) {
53 | if let stackView = self as? MortarStackView {
54 | addViewBoxes.compactMap(\.view).forEach { stackView.addArrangedSubview($0) }
55 | } else {
56 | addViewBoxes.compactMap(\.view).forEach(addSubview)
57 | }
58 | }
59 | }
60 |
61 | public protocol MortarFrameInitializable {
62 | init(frame: CGRect)
63 | }
64 |
65 | extension MortarView: MortarFrameInitializable {}
66 |
67 | public extension MortarFrameInitializable where Self: MortarView {
68 | /// Initializes a `MortarView` with subviews using a result builder.
69 | /// - Parameter subviewBoxes: A closure that returns an array of `MortarAddViewBox` instances.
70 | init(@MortarAddSubviewsBuilder _ subviewBoxes: () -> [MortarAddViewBox]) {
71 | self.init(frame: .zero)
72 | MortarMainThreadLayoutStack.execute {
73 | process(subviewBoxes())
74 | }
75 | }
76 |
77 | /// Initializes a `MortarView` with subviews using a result builder, allowing the view to be referenced in the closure.
78 | /// - Parameter subviewBoxes: A closure that takes the current view and returns an array of `MortarAddViewBox` instances.
79 | init(@MortarAddSubviewsBuilder _ subviewBoxes: (Self) -> [MortarAddViewBox]) {
80 | self.init(frame: .zero)
81 | MortarMainThreadLayoutStack.execute {
82 | process(subviewBoxes(self))
83 | }
84 | }
85 | }
86 |
87 | public protocol MortarConfigurableView {}
88 |
89 | extension MortarView: MortarConfigurableView {}
90 |
91 | public extension MortarConfigurableView where Self: MortarView {
92 | /// Configures the view by adding subviews using a result builder.
93 | /// - Parameter configureBlock: A closure that returns an array of `MortarAddViewBox` instances.
94 | /// - Returns: The configured view (`Self`).
95 | @discardableResult
96 | func configure(@MortarAddSubviewsBuilder _ configureBlock: () -> [MortarAddViewBox]) -> Self {
97 | MortarMainThreadLayoutStack.execute {
98 | process(configureBlock())
99 | }
100 | return self
101 | }
102 |
103 | /// Configures the view by adding subviews using a result builder, allowing access to the view within the closure.
104 | /// - Parameter configureBlock: A closure that takes `Self` and returns an array of `MortarAddViewBox` instances.
105 | /// - Returns: The configured view (`Self`).
106 | @discardableResult
107 | func configure(@MortarAddSubviewsBuilder _ configureBlock: (Self) -> [MortarAddViewBox]) -> Self {
108 | MortarMainThreadLayoutStack.execute {
109 | process(configureBlock(self))
110 | }
111 | return self
112 | }
113 | }
114 |
115 | // MARK: - Specific Subview Overrides
116 |
117 | #if os(iOS) || os(tvOS)
118 |
119 | public extension MortarFrameInitializable where Self: UIButton {
120 | /// Initializes a `MortarView` with subviews using a result builder.
121 | /// - Parameter subviewBoxes: A closure that returns an array of `MortarAddViewBox` instances.
122 | init(type: UIButton.ButtonType, @MortarAddSubviewsBuilder _ subviewBoxes: () -> [MortarAddViewBox]) {
123 | self.init(type: type)
124 | MortarMainThreadLayoutStack.execute {
125 | process(subviewBoxes())
126 | }
127 | }
128 |
129 | /// Initializes a `MortarView` with subviews using a result builder, allowing the view to be referenced in the closure.
130 | /// - Parameter subviewBoxes: A closure that takes the current view and returns an array of `MortarAddViewBox` instances.
131 | init(type: UIButton.ButtonType, @MortarAddSubviewsBuilder _ subviewBoxes: (Self) -> [MortarAddViewBox]) {
132 | self.init(type: type)
133 | MortarMainThreadLayoutStack.execute {
134 | process(subviewBoxes(self))
135 | }
136 | }
137 | }
138 |
139 | #endif
140 |
--------------------------------------------------------------------------------
/Mortar/MortarTextDelegation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarTextDelegation.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import CombineEx
9 | import UIKit
10 |
11 | @MainActor public protocol MortarExtendableTextField: UITextField {}
12 | extension UITextField: MortarExtendableTextField {}
13 |
14 | public extension MortarExtendableTextField {
15 | /// Configures the UITextField's delegation with a block.
16 | ///
17 | /// - Parameter configureBlock: A closure that configures the gesture recognizer delegate.
18 | /// - Returns: The configured gesture recognizer instance.
19 | func handleDelegation(_ configureBlock: (MortarTextFieldDelegateHandler) -> Void) {
20 | let delegateHandler = MortarTextFieldDelegateHandler()
21 | delegate = delegateHandler
22 | configureBlock(delegateHandler)
23 | permanentlyAssociate(delegateHandler)
24 | }
25 | }
26 |
27 | @MainActor
28 | public final class MortarTextFieldDelegateHandler: NSObject, UITextFieldDelegate {
29 | // Public-configurable handlers
30 |
31 | @available(iOS 17.0, tvOS 17.0, *)
32 | public var shouldClear: ((T) -> Bool)? = nil
33 |
34 | @available(iOS 17.0, tvOS 17.0, *)
35 | public var shouldReturn: ((T) -> Bool)? = nil
36 |
37 | @available(iOS 17.0, tvOS 17.0, *)
38 | public var didEndEditing: ((T) -> Void)? = nil
39 |
40 | @available(iOS 17.0, tvOS 17.0, *)
41 | public var didEndEditingWithReason: ((T, UITextField.DidEndEditingReason) -> Void)? = nil
42 |
43 | @available(iOS 17.0, tvOS 17.0, *)
44 | public var didBeginEditing: ((T) -> Void)? = nil
45 |
46 | @available(iOS 17.0, tvOS 17.0, *)
47 | public var didChangeSelection: ((T) -> Void)? = nil
48 |
49 | @available(iOS 17.0, tvOS 17.0, *)
50 | public var shouldEndEditing: ((T) -> Bool)? = nil
51 |
52 | @available(iOS 17.0, tvOS 17.0, *)
53 | public var shouldBeginEditing: ((T) -> Bool)? = nil
54 |
55 | @available(iOS 17.0, tvOS 17.0, *)
56 | public var willPresentEditMenu: ((T, UIEditMenuInteractionAnimating) -> Void)? = nil
57 |
58 | @available(iOS 17.0, tvOS 17.0, *)
59 | public var didPresentEditMenu: ((T, UIEditMenuInteractionAnimating) -> Void)? = nil
60 |
61 | @available(iOS 17.0, tvOS 17.0, *)
62 | public var shouldChangeCharactersInRange: ((T, NSRange, String) -> Bool)? = nil
63 |
64 | @available(iOS 17.0, tvOS 17.0, *)
65 | public var shouldChangeCharactersInRanges: ((T, [NSValue], String) -> Bool)? = nil
66 |
67 | @available(iOS 17.0, tvOS 17.0, *)
68 | public var editMenuForCharactersInRange: ((T, NSRange, [UIMenuElement]) -> UIMenu?)? = nil
69 |
70 | @available(iOS 17.0, tvOS 17.0, *)
71 | public var editMenuForCharactersInRanges: ((T, [NSValue], [UIMenuElement]) -> UIMenu?)? = nil
72 |
73 | // Delegate Implementation
74 |
75 | @available(iOS 17.0, tvOS 17.0, *)
76 | public func textFieldShouldClear(_ textField: UITextField) -> Bool {
77 | shouldClear?(textField as! T) ?? true
78 | }
79 |
80 | @available(iOS 17.0, tvOS 17.0, *)
81 | public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
82 | shouldReturn?(textField as! T) ?? true
83 | }
84 |
85 | @available(iOS 17.0, tvOS 17.0, *)
86 | public func textFieldDidEndEditing(_ textField: UITextField) {
87 | didEndEditing?(textField as! T)
88 | }
89 |
90 | @available(iOS 17.0, tvOS 17.0, *)
91 | public func textFieldDidBeginEditing(_ textField: UITextField) {
92 | didBeginEditing?(textField as! T)
93 | }
94 |
95 | @available(iOS 17.0, tvOS 17.0, *)
96 | public func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
97 | shouldEndEditing?(textField as! T) ?? true
98 | }
99 |
100 | @available(iOS 17.0, tvOS 17.0, *)
101 | public func textFieldDidChangeSelection(_ textField: UITextField) {
102 | didChangeSelection?(textField as! T)
103 | }
104 |
105 | @available(iOS 17.0, tvOS 17.0, *)
106 | public func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
107 | shouldBeginEditing?(textField as! T) ?? true
108 | }
109 |
110 | @available(iOS 17.0, tvOS 17.0, *)
111 | public func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
112 | didEndEditingWithReason?(textField as! T, reason)
113 | }
114 |
115 | @available(iOS 17.0, tvOS 17.0, *)
116 | public func textField(_ textField: UITextField, willDismissEditMenuWith animator: any UIEditMenuInteractionAnimating) {
117 | didPresentEditMenu?(textField as! T, animator)
118 | }
119 |
120 | @available(iOS 17.0, tvOS 17.0, *)
121 | public func textField(_ textField: UITextField, willPresentEditMenuWith animator: any UIEditMenuInteractionAnimating) {
122 | willPresentEditMenu?(textField as! T, animator)
123 | }
124 |
125 | @available(iOS 17.0, tvOS 17.0, *)
126 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
127 | shouldChangeCharactersInRange?(textField as! T, range, string) ?? true
128 | }
129 |
130 | @available(iOS 17.0, tvOS 17.0, *)
131 | public func textField(_ textField: UITextField, shouldChangeCharactersInRanges ranges: [NSValue], replacementString string: String) -> Bool {
132 | shouldChangeCharactersInRanges?(textField as! T, ranges, string) ?? true
133 | }
134 |
135 | @available(iOS 17.0, tvOS 17.0, *)
136 | public func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
137 | editMenuForCharactersInRange?(textField as! T, range, suggestedActions)
138 | }
139 |
140 | @available(iOS 17.0, tvOS 17.0, *)
141 | public func textField(_ textField: UITextField, editMenuForCharactersInRanges ranges: [NSValue], suggestedActions: [UIMenuElement]) -> UIMenu? {
142 | editMenuForCharactersInRanges?(textField as! T, ranges, suggestedActions)
143 | }
144 | }
145 |
146 | #endif
147 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/CollectionView/ManagedCollectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedCollectionView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public final class ManagedCollectionView: UICollectionView {
11 | public var sections: [ManagedCollectionViewSection] = [] {
12 | didSet {
13 | updateDataSource()
14 | }
15 | }
16 |
17 | public var singleSectionItems: [any ManagedCollectionViewCellModel] {
18 | get {
19 | sections.first?.items ?? []
20 | }
21 |
22 | set {
23 | sections = [ManagedCollectionViewSection(id: "SingleSection", items: newValue)]
24 | }
25 | }
26 |
27 | private var diffableDataSource: UICollectionViewDiffableDataSource?
28 | private var registeredCellIdentifiers: Set = []
29 | private var registeredReusableIdentifiers: Set = []
30 |
31 | override public init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
32 | super.init(frame: frame, collectionViewLayout: layout)
33 |
34 | self.delegate = self
35 |
36 | self.diffableDataSource = UICollectionViewDiffableDataSource(collectionView: self) { [weak self] _, indexPath, _ in
37 | guard let self else {
38 | return nil
39 | }
40 |
41 | let viewModel = sections[indexPath.section].items[indexPath.row]
42 | return viewModel.__dequeueCell(self, indexPath)
43 | }
44 |
45 | diffableDataSource?.supplementaryViewProvider = { [weak self] _, kind, indexPath -> UICollectionReusableView? in
46 | guard let self else {
47 | return nil
48 | }
49 |
50 | switch kind {
51 | case UICollectionView.elementKindSectionHeader:
52 | return sections[indexPath.section].header.flatMap {
53 | $0.__dequeueReusableView(self, indexPath)
54 | } ?? UICollectionReusableView()
55 | case UICollectionView.elementKindSectionFooter:
56 | return sections[indexPath.section].footer.flatMap {
57 | $0.__dequeueReusableView(self, indexPath)
58 | } ?? UICollectionReusableView()
59 | default:
60 | return nil
61 | }
62 | }
63 | }
64 |
65 | @available(*, unavailable)
66 | required init?(coder: NSCoder) {
67 | fatalError("init(coder:) has not been implemented")
68 | }
69 |
70 | private func updateDataSource() {
71 | var snapshot = NSDiffableDataSourceSnapshot()
72 | for section in sections {
73 | snapshot.appendSections([section.id])
74 | snapshot.appendItems(section.items.map { $0.id as String })
75 | }
76 | diffableDataSource?.apply(snapshot, animatingDifferences: false)
77 | }
78 | }
79 |
80 | extension ManagedCollectionView: UICollectionViewDelegate {
81 | public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
82 | collectionView.deselectItem(at: indexPath, animated: true)
83 | sections[indexPath.section].items[indexPath.item].onSelect?(collectionView as! ManagedCollectionView, indexPath)
84 | }
85 | }
86 |
87 | private extension ManagedCollectionView {
88 | func dequeueCell(_ type: T.Type, for indexPath: IndexPath) -> T where T: ClassReusable {
89 | registerCellIfNeeded(type)
90 | return dequeueReusableCell(withReuseIdentifier: type.typeReuseIdentifier, for: indexPath) as! T
91 | }
92 |
93 | func dequeueReusableView(_ type: T.Type, for indexPath: IndexPath) -> T where T: ClassReusable {
94 | registerReusableViewIfNeeded(type)
95 | return dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: type.typeReuseIdentifier, for: indexPath) as! T
96 | }
97 | }
98 |
99 | private extension ManagedCollectionView {
100 | func registerCellIfNeeded(_ type: (some ClassReusable).Type) {
101 | guard !registeredCellIdentifiers.contains(type.typeReuseIdentifier) else { return }
102 | register(type.self, forCellWithReuseIdentifier: type.typeReuseIdentifier)
103 | registeredCellIdentifiers.insert(type.typeReuseIdentifier)
104 | }
105 |
106 | func registerReusableViewIfNeeded(_ type: (some ClassReusable).Type) {
107 | guard !registeredReusableIdentifiers.contains(type.typeReuseIdentifier) else { return }
108 | register(type.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: type.typeReuseIdentifier)
109 | register(type.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: type.typeReuseIdentifier)
110 | registeredReusableIdentifiers.insert(type.typeReuseIdentifier)
111 | }
112 | }
113 |
114 | @MainActor
115 | private extension ManagedCollectionViewCellModel {
116 | func __dequeueCell(_ collectionView: ManagedCollectionView, _ indexPath: IndexPath) -> Cell {
117 | let cell = collectionView.dequeueCell(Cell.self, for: indexPath)
118 | cell.update(model: self as! Cell.Model)
119 | return cell
120 | }
121 | }
122 |
123 | @MainActor
124 | private extension ManagedCollectionReusableViewModel {
125 | func __dequeueReusableView(_ collectionView: ManagedCollectionView, _ indexPath: IndexPath) -> ReusableView {
126 | let reusableView = collectionView.dequeueReusableView(ReusableView.self, for: indexPath)
127 | reusableView.update(model: self as! ReusableView.Model)
128 | return reusableView
129 | }
130 | }
131 |
132 | // MARK: Reusable
133 |
134 | /// This internal protocol automates reuse identification for managed cells
135 | /// so that they simply use their class name as the reuse identifier.
136 | private protocol ClassReusable: AnyObject {
137 | static var typeReuseIdentifier: String { get }
138 | }
139 |
140 | extension ClassReusable {
141 | static var typeReuseIdentifier: String {
142 | String(describing: self)
143 | }
144 | }
145 |
146 | extension UICollectionReusableView: ClassReusable {}
147 |
148 | #endif
149 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo/MainMenuViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainMenuViewController.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | import Mortar
7 |
8 | class MainMenuViewController: UIViewController {
9 | private let model = MainMenuViewControllerModel()
10 |
11 | override func loadView() {
12 | title = "Mortar Demo"
13 |
14 | // Main View
15 | view = UIContainer {
16 | ManagedTableView {
17 | $0.layout.edges == $0.parentLayout.edges
18 |
19 | $0.bind(\.sections) <~ Property<[ManagedTableViewSection]>.combineLatest(
20 | model.demoScreens,
21 | model.sortStarred,
22 | model.starList
23 | ).map { [unowned self] demoScreens, sortStarred, starList -> [ManagedTableViewSection] in
24 | return makeSections(
25 | demoScreens: demoScreens,
26 | sortStarred: sortStarred,
27 | starList: starList
28 | )
29 | }
30 | }
31 | }
32 |
33 | // Sort selector
34 | navigationItem.bind(\.rightBarButtonItems) <~ model
35 | .sortStarred.map { [unowned self] sortStarred in
36 | return [
37 | UIBarButtonItem(
38 | image: .init(systemName: sortStarred ? "star.fill" : "star"),
39 | style: .plain,
40 | target: model,
41 | action: #selector(MainMenuViewControllerModel.toggleSortStarred)
42 | ),
43 | ]
44 | }
45 | }
46 |
47 | private func makeSections(
48 | demoScreens: [DemoScreen],
49 | sortStarred: Bool,
50 | starList: Set
51 | ) -> [ManagedTableViewSection] {
52 | if sortStarred {
53 | let starredScreens = demoScreens.filter { starList.contains($0.title) }
54 | let unstarredScreens = demoScreens.filter { !starList.contains($0.title) }
55 |
56 | return [
57 | ManagedTableViewSection(
58 | rows: makeRows(demoScreens: starredScreens, starList: starList)
59 | ),
60 | ManagedTableViewSection(
61 | rows: makeRows(demoScreens: unstarredScreens, starList: starList)
62 | ),
63 | ]
64 | } else {
65 | return [
66 | ManagedTableViewSection(
67 | rows: makeRows(demoScreens: demoScreens, starList: starList)
68 | ),
69 | ]
70 | }
71 | }
72 |
73 | private func makeRows(demoScreens: [DemoScreen], starList: Set) -> [MainMenuRowCell.Model] {
74 | demoScreens.map { [weak self, model] demoScreen in
75 | return MainMenuRowCell.Model(
76 | title: demoScreen.title,
77 | isStarred: starList.contains(demoScreen.title),
78 | starTapHandler: .immediate { [model] in
79 | model.toggleStarred(for: demoScreen.title)
80 | },
81 | onSelect: { [weak self] in
82 | let controller = demoScreen.viewControllerType.init()
83 | self?.navigationController?.pushViewController(controller, animated: true)
84 | }
85 | )
86 | }
87 | }
88 | }
89 |
90 | // MARK: - View Controller Model
91 |
92 | private class MainMenuViewControllerModel {
93 | // Public interface
94 |
95 | private(set) lazy var demoScreens = Property(value: demoScreenArray)
96 | private(set) lazy var sortStarred = Property(mutableSortStarred)
97 | private(set) lazy var starList = Property(mutableStarList.map { Set($0) })
98 |
99 | func toggleStarred(for title: String) {
100 | mutableStarList.modify { list in
101 | if list.contains(title) {
102 | list.removeAll { $0 == title }
103 | } else {
104 | list.append(title)
105 | }
106 | }
107 | }
108 |
109 | @objc func toggleSortStarred() {
110 | mutableSortStarred.modify { $0 = !$0 }
111 | }
112 |
113 | // Private implementation
114 |
115 | private let demoScreenArray: [DemoScreen] = [
116 | .init("Layout Features", LayoutFeaturesViewController.self),
117 | .init("Reactive Features", ReactiveFeaturesViewController.self),
118 | .init("Basic ManagedTableView", BasicManagedTableViewController.self),
119 | ]
120 |
121 | private let mutableStarList = PersistentProperty<[String]>(
122 | environment: storageEnvironment,
123 | key: "starList",
124 | defaultValue: []
125 | )
126 |
127 | private let mutableSortStarred = PersistentProperty(
128 | environment: storageEnvironment,
129 | key: "sortStarred",
130 | defaultValue: false
131 | )
132 | }
133 |
134 | private struct DemoScreen {
135 | let title: String
136 | let viewControllerType: UIViewController.Type
137 |
138 | init(_ title: String, _ viewControllerType: UIViewController.Type) {
139 | self.title = title
140 | self.viewControllerType = viewControllerType
141 | }
142 | }
143 |
144 | // MARK: - ManagedTableView Classes
145 |
146 | private class MainMenuRowCell: UITableViewCell, ManagedTableViewCell {
147 | struct Model: ManagedTableViewCellModel, ArbitrarilyIdentifiable {
148 | typealias Cell = MainMenuRowCell
149 |
150 | let id: String = UUID().uuidString
151 | let title: String
152 | let isStarred: Bool
153 | let starTapHandler: ActionTrigger
154 | let onSelect: ((ManagedTableView, IndexPath) -> Void)?
155 | }
156 |
157 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
158 | super.init(style: style, reuseIdentifier: reuseIdentifier)
159 | accessoryType = .disclosureIndicator
160 | contentView.configure {
161 | UIHStack {
162 | $0.alignment = .center
163 | $0.layout.edges == $0.parentLayout.edges
164 |
165 | UIButton(type: .custom) {
166 | $0.layout.size == CGSize(width: 44, height: 44)
167 | $0.contentHuggingHorizontal = .required
168 | $0.sink(model.map(\.isStarred)) {
169 | $0.setImage(UIImage(systemName: $1 ? "star.fill" : "star"), for: .normal)
170 | }
171 | $0.handleEvents(.touchUpInside, model.map(\.starTapHandler))
172 | }
173 | UILabel {
174 | $0.bind(\.text) <~ model.map(\.title)
175 | }
176 | }
177 | }
178 | }
179 |
180 | @available(*, unavailable)
181 | required init?(coder: NSCoder) {
182 | fatalError("init(coder:) has not been implemented")
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/Mortar/MortarGestureRecognizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MortarGestureRecognizer.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import CombineEx
9 |
10 | @MainActor public protocol MortarExtendableGestureRecognizer: MortarGestureRecognizer {}
11 | extension MortarGestureRecognizer: MortarExtendableGestureRecognizer {}
12 |
13 | public extension MortarExtendableGestureRecognizer {
14 | /// Initializes a gesture recognizer with a configuration block.
15 | ///
16 | /// - Parameter configuration: A closure that configures the gesture recognizer.
17 | init(configuration: (Self) -> Void) {
18 | self.init(target: nil, action: nil)
19 | configuration(self)
20 | }
21 |
22 | /// Configures the gesture recognizer with a block and returns self for chaining.
23 | ///
24 | /// - Parameter configBlock: A closure that configures the gesture recognizer.
25 | /// - Returns: The configured gesture recognizer instance.
26 | func configure(_ configBlock: (Self) -> Void) -> Self {
27 | configBlock(self)
28 | return self
29 | }
30 |
31 | /// Assigns an action handler to be called when the gesture is recognized.
32 | ///
33 | /// - Parameter actionHandler: A closure that handles the gesture recognition event.
34 | /// - Returns: The configured gesture recognizer instance.
35 | func handleAction(_ actionHandler: @escaping (Self) -> Void) {
36 | let typedActionHandler: (MortarGestureRecognizer) -> Void = {
37 | actionHandler($0 as! Self)
38 | }
39 |
40 | let target = MortarGestureRecognizerTarget(actionHandler: typedActionHandler)
41 | addTarget(target, action: #selector(MortarGestureRecognizerTarget.handleGesture))
42 | permanentlyAssociate(target)
43 | }
44 |
45 | /// Configures the gesture recognizer's delegate with a block.
46 | ///
47 | /// - Parameter configureBlock: A closure that configures the gesture recognizer delegate.
48 | /// - Returns: The configured gesture recognizer instance.
49 | func handleDelegation(_ configureBlock: (MortarGestureRecognizerDelegateHandler) -> Void) {
50 | let delegateHandler = MortarGestureRecognizerDelegateHandler()
51 | delegate = delegateHandler
52 | configureBlock(delegateHandler)
53 | permanentlyAssociate(delegateHandler)
54 | }
55 | }
56 |
57 | @MainActor
58 | private final class MortarGestureRecognizerTarget: NSObject {
59 | let actionHandler: (MortarGestureRecognizer) -> Void
60 |
61 | init(actionHandler: @escaping (MortarGestureRecognizer) -> Void) {
62 | self.actionHandler = actionHandler
63 | }
64 |
65 | @objc public func handleGesture(_ gestureRecognizer: MortarGestureRecognizer) {
66 | actionHandler(gestureRecognizer)
67 | }
68 | }
69 |
70 | @MainActor
71 | public final class MortarGestureRecognizerDelegateHandler: NSObject, MortarGestureRecognizerDelegate {
72 | // Public-configurable handlers
73 |
74 | /// Determines whether the gesture recognizer should begin recognizing gestures.
75 | @available(iOS 17.0, tvOS 17.0, *)
76 | public var shouldBegin: ((T) -> Bool)? = nil
77 |
78 | /// Determines whether the gesture recognizer should recognize simultaneously with other gesture recognizers.
79 | @available(iOS 17.0, tvOS 17.0, *)
80 | public var shouldRecognizeSimultaneouslyWithOther: ((UIGestureRecognizer) -> Bool)? = nil
81 |
82 | /// Determines whether the gesture recognizer should require another gesture recognizer to fail.
83 | @available(iOS 17.0, tvOS 17.0, *)
84 | public var shouldRequireFailureOfOther: ((UIGestureRecognizer) -> Bool)? = nil
85 |
86 | /// Determines whether the gesture recognizer should be required to fail by another gesture recognizer.
87 | @available(iOS 17.0, tvOS 17.0, *)
88 | public var shouldBeRequiredToFailByOther: ((UIGestureRecognizer) -> Bool)? = nil
89 |
90 | /// Determines whether the gesture recognizer should receive a touch.
91 | @available(iOS 17.0, tvOS 17.0, *)
92 | public var shouldReceiveTouch: ((UITouch) -> Bool)? = nil
93 |
94 | /// Determines whether the gesture recognizer should receive a press.
95 | @available(iOS 17.0, tvOS 17.0, *)
96 | public var shouldReceivePress: ((UIPress) -> Bool)? = nil
97 |
98 | /// Determines whether the gesture recognizer should receive an event.
99 | @available(iOS 17.0, tvOS 17.0, *)
100 | public var shouldReceiveEvent: ((UIEvent) -> Bool)? = nil
101 |
102 | // Delegate Implementation
103 |
104 | /// Determines whether the gesture recognizer should begin recognizing gestures.
105 | @available(iOS 17.0, tvOS 17.0, *)
106 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
107 | shouldBegin?(gestureRecognizer as! T) ?? true
108 | }
109 |
110 | /// Determines whether the gesture recognizer should recognize simultaneously with other gesture recognizers.
111 | @available(iOS 17.0, tvOS 17.0, *)
112 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
113 | shouldRecognizeSimultaneouslyWithOther?(otherGestureRecognizer) ?? false
114 | }
115 |
116 | /// Determines whether the gesture recognizer should require another gesture recognizer to fail.
117 | @available(iOS 17.0, tvOS 17.0, *)
118 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
119 | shouldRequireFailureOfOther?(otherGestureRecognizer) ?? false
120 | }
121 |
122 | /// Determines whether the gesture recognizer should be required to fail by another gesture recognizer.
123 | @available(iOS 17.0, tvOS 17.0, *)
124 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
125 | shouldBeRequiredToFailByOther?(otherGestureRecognizer) ?? false
126 | }
127 |
128 | /// Determines whether the gesture recognizer should receive a touch.
129 | @available(iOS 17.0, tvOS 17.0, *)
130 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
131 | shouldReceiveTouch?(touch) ?? true
132 | }
133 |
134 | /// Determines whether the gesture recognizer should receive a press.
135 | @available(iOS 17.0, tvOS 17.0, *)
136 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool {
137 | shouldReceivePress?(press) ?? true
138 | }
139 |
140 | /// Determines whether the gesture recognizer should receive an event.
141 | @available(iOS 17.0, tvOS 17.0, *)
142 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool {
143 | shouldReceiveEvent?(event) ?? true
144 | }
145 | }
146 |
147 | #endif
148 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mortar 3
2 |
3 | > Mortar 3 is incompatible with previous versions, and aims to solve different problems. Check the git tags to find previous versions.
4 |
5 | > Mortar 3 is still very much a work in progress, and fundamental API decisions may change at any moment. Please let me know If you choose to use this library for a production app so that I can be more cognizant of impact.
6 |
7 | ## Project Summary
8 |
9 | Mortar is a Swift DSL (Domain Specific Language) that enables declarative, anonymous view hierarchy construction using UIKit. It bridges the gap between traditional UIKit development and SwiftUI-like syntax while maintaining full compatibility with existing UIKit infrastructure.
10 |
11 | ### Key Differentiators
12 |
13 | 1. **Anonymous View Construction**: Create complete view hierarchies without naming views or defining them outside of their usage context
14 | 2. **Declarative Layout**: Provides a clean syntax for AutoLayout constraints that works with UIKit's native classes
15 | 3. **Reactive Integration**: Seamlessly integrates with CombineEx for reactive programming patterns
16 | 4. **Managed Views**: Specialized components for UITableView and UICollectionView that work with model-driven data
17 |
18 | ### Example View Construction
19 |
20 | ```swift
21 | import Mortar
22 |
23 | class MyViewController: UIViewController {
24 | override func loadView() {
25 | view = UIContainer {
26 | $0.backgroundColor = .darkGray
27 |
28 | UIVStack {
29 | $0.alignment = .center
30 | $0.backgroundColor = .lightGray
31 | $0.layout.sides == $0.parentLayout.sideMargins
32 | $0.layout.centerY == $0.parentLayout.centerY
33 |
34 | UILabel {
35 | $0.layout.height == 44
36 | $0.text = "Hello, World!"
37 | $0.textColor = .red
38 | $0.textAlignment = .center
39 | }
40 |
41 | UIButton(type: .roundedRect) {
42 | $0.setTitle("Button", for: .normal)
43 | $0.handleEvents(.touchUpInside) { NSLog("touched \($0)") }
44 | }
45 | }
46 | }
47 | }
48 | }
49 | ```
50 |
51 | - No need to name views or define them outside of their usage context
52 | - Complete layout DSL available for anonymous constraints
53 | - Full UIKit compatibility maintained
54 |
55 | ## Problems Solved
56 |
57 | Traditional UIKit development requires:
58 | - Explicit view naming and definition
59 | - Verbose AutoLayout constraint code
60 | - Complex separation of concerns for reactive state management
61 |
62 | Mortar solves these issues by providing:
63 | - Anonymous view creation with inline layout constraints
64 | - Clean, readable syntax for AutoLayout expressions
65 | - Reactive programming patterns that work naturally with UIKit
66 |
67 | ## Architecture and Design Decisions
68 |
69 | ### Why Mortar Exists
70 |
71 | Mortar was created to address dissatisfactions with SwiftUI while maintaining the benefits of UIKit. The framework combines the best of both:
72 | - Avoids treating entire view hierarchies as immutable structs
73 | - Maintains UIKit's performance characteristics and flexibility
74 | - Provides clean separation of view logic from business logic
75 | - Enables anonymous, declarative UI construction
76 |
77 | ### Core Concepts
78 |
79 | 1. **Result Builder Pattern**: Uses `MortarAddSubviewsBuilder` to enable anonymous view creation within UIKit's initialization blocks
80 | 2. **Layout Properties**: Extends UIView with layout properties that provide access to parent and referenced layouts
81 | 3. **Reactive Extensions**: Integrates with CombineEx for clean reactive programming patterns
82 | 4. **Managed Views**: Provides specialized components for collection views that work with model-driven data
83 |
84 | ### Technical Approach
85 |
86 | The framework leverages Swift's result builder feature to create a DSL that allows:
87 | - Views to be created and added without explicit naming
88 | - Layout constraints to be expressed in a natural, readable syntax
89 | - Reactive patterns to be applied inline with view construction
90 | - Complex UI hierarchies to be built in a single, declarative block
91 |
92 | ## Usage Examples
93 |
94 | ### Layout Constraints
95 |
96 | - DSL allows you to declare layout constraints inline with UIView configuration
97 | - Access to parent layout anchors via `parentLayout`
98 | - Multi-constraint guides (e.g., `sides` combines leading/trailing)
99 | - Support for inequalities and constraint modifications
100 | - Layout references for cross-view constraints
101 |
102 | ```swift
103 | // Basic constraint against parent layout
104 | $0.layout.centerY == $0.parentLayout.centerY
105 |
106 | // Multi-constraint guide in single expression
107 | $0.layout.sides == $0.parentLayout.sideMargins
108 |
109 | // Constraint to constants
110 | $0.layout.size == CGSize(width: 100, height: 100)
111 |
112 | // Inequalities
113 | $0.layout.trailing == $0.parentLayout.trailing
114 |
115 | // Constraint modification after creation
116 | let group = $0.layout.center == $0.parentLayout.center
117 | group.layoutConstraints.first?.constant += 20
118 | ```
119 |
120 | ### Reactive Programming
121 |
122 | - Integration with CombineEx framework
123 | - Inline event handling and property binding
124 | - Publisher sinking for complex view updates
125 |
126 | ```swift
127 | // Handle UIControl events with CombineEx Actions
128 | $0.handleEvents(.valueChanged, model.toggleStateAction) { $0.isOn }
129 |
130 | // Bind publishers to view properties
131 | $0.bind(\.text) <~ model.toggleState.map { "Toggle is \($0)" }
132 |
133 | // Sink publishers for complex view updates
134 | $0.sink(model.someVoidPublisher) { view in
135 | // Void publishers handling
136 | }
137 |
138 | $0.sink(model.someValuePublisher) { view, value in
139 | // Value publishers handling
140 | }
141 | ```
142 |
143 | ### Managed Table Views
144 |
145 | - Specialized components for UITableView and UICollectionView
146 | - Model-driven data binding
147 | - Automatic view reuse and model updating
148 |
149 | ```swift
150 | // Define model and cell classes
151 | private struct SimpleTextRowModel: ManagedTableViewCellModel {
152 | typealias Cell = SimpleTextRowCell
153 |
154 | let text: String
155 | }
156 |
157 | private final class SimpleTextRowCell: UITableViewCell, ManagedTableViewCell {
158 | typealias Model = SimpleTextRowModel
159 | }
160 |
161 | // Use in view controller
162 | class BasicManagedTableViewController: UIViewController {
163 | override func loadView() {
164 | view = UIContainer {
165 | $0.backgroundColor = .white
166 |
167 | ManagedTableView {
168 | $0.layout.edges == $0.parentLayout.edges
169 | $0.sections <~ Property(value: [self.makeSection()])
170 | }
171 | }
172 | }
173 |
174 | private func makeSection() -> ManagedTableViewSection {
175 | ManagedTableViewSection(
176 | rows: [
177 | SimpleTextRowModel(text: "Simple row 1"),
178 | SimpleTextRowModel(text: "Simple row 2"),
179 | SimpleTextRowModel(text: "Simple row 3"),
180 | ]
181 | )
182 | }
183 | }
184 | ```
185 |
186 | ## Getting Started
187 |
188 | 1. Add Mortar as a dependency in your Package.swift:
189 | ```swift
190 | dependencies: [
191 | .package(url: "https://github.com/jmfieldman/Mortar.git", from: )
192 | ]
193 | ```
194 |
195 | 2. Import Mortar in your code:
196 | ```swift
197 | import Mortar
198 | ```
199 |
200 | 3. Start building anonymous views with declarative syntax
201 |
202 | ## License
203 |
204 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
205 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Guide for AI Agents: Using Mortar Library for Swift UI Development
2 |
3 | This guide provides comprehensive instructions for AI agents on how to use the Mortar library to create iOS UI elements declaratively in Swift.
4 |
5 | ## Overview
6 |
7 | Mortar is a Swift DSL (Domain Specific Language) that enables declarative, anonymous view hierarchy construction using UIKit. It bridges the gap between traditional UIKit development and SwiftUI-like syntax while maintaining full compatibility with existing UIKit infrastructure.
8 |
9 | ## Key Features
10 |
11 | 1. **Anonymous View Construction**: Create complete view hierarchies without naming views or defining them outside of their usage context
12 | 2. **Declarative Layout**: Provides a clean syntax for AutoLayout constraints that works with UIKit's native classes
13 | 3. **Reactive Integration**: Seamlessly integrates with CombineEx for reactive programming patterns
14 | 4. **Managed Views**: Specialized components for UITableView and UICollectionView that work with model-driven data
15 |
16 | ## Core Concepts
17 |
18 | ### Result Builder Pattern
19 | Mortar uses `MortarAddSubviewsBuilder` to enable anonymous view creation within UIKit's initialization blocks. This allows views to be created and added inline without explicit naming.
20 |
21 | ### Layout Properties
22 | Views have access to layout properties that provide constraint capabilities:
23 | - `layout`: Access to the view's own layout anchors
24 | - `parentLayout`: Access to parent layout anchors for constraints
25 | - `referencedLayout(_:)`: Access to referenced layout anchors for cross-view constraints
26 |
27 | ### Reactive Programming
28 | Mortar integrates with CombineEx framework for reactive programming patterns:
29 | - Event handling with `handleEvents()`
30 | - Property binding with `bind()`
31 | - Publisher sinking with `sink()`
32 |
33 | ## Basic Usage Patterns
34 |
35 | ### Creating Views with Anonymous Hierarchy
36 |
37 | ```swift
38 | import Mortar
39 |
40 | class MyViewController: UIViewController {
41 | override func loadView() {
42 | view = UIContainer {
43 | $0.backgroundColor = .darkGray
44 |
45 | UIVStack {
46 | $0.alignment = .center
47 | $0.backgroundColor = .lightGray
48 | $0.layout.sides == $0.parentLayout.sideMargins
49 | $0.layout.centerY == $0.parentLayout.centerY
50 |
51 | UILabel {
52 | $0.layout.height == 44
53 | $0.text = "Hello, World!"
54 | $0.textColor = .red
55 | $0.textAlignment = .center
56 | }
57 |
58 | UIButton(type: .roundedRect) {
59 | $0.setTitle("Button", for: .normal)
60 | $0.handleEvents(.touchUpInside) { NSLog("touched \($0)") }
61 | }
62 | }
63 | }
64 | }
65 | }
66 | ```
67 |
68 | ### Layout Constraints
69 |
70 | ```swift
71 | // Basic constraint against parent layout
72 | $0.layout.centerY == $0.parentLayout.centerY
73 |
74 | // Multi-constraint guide in single expression
75 | $0.layout.sides == $0.parentLayout.sideMargins
76 |
77 | // Constraint to constants
78 | $0.layout.size == CGSize(width: 100, height: 100)
79 |
80 | // Inequalities
81 | $0.layout.trailing == $0.parentLayout.trailing
82 |
83 | // Constraint modification after creation
84 | let group = $0.layout.center == $0.parentLayout.center
85 | group.layoutConstraints.first?.constant += 20
86 | ```
87 |
88 | ### Reactive Programming
89 |
90 | ```swift
91 | // Handle UIControl events with CombineEx Actions
92 | $0.handleEvents(.valueChanged, model.toggleStateAction) { $0.isOn }
93 |
94 | // Bind publishers to view properties
95 | $0.bind(\.text) <~ model.toggleState.map { "Toggle is \($0)" }
96 |
97 | // Sink publishers for complex view updates
98 | $0.sink(model.someVoidPublisher) { view in
99 | // Void publishers handling
100 | }
101 |
102 | $0.sink(model.someValuePublisher) { view, value in
103 | // Value publishers handling
104 | }
105 | ```
106 |
107 | ## Working with Managed Views
108 |
109 | ### Managed Table Views
110 |
111 | ```swift
112 | // Define model and cell classes
113 | private struct SimpleTextRowModel: ManagedTableViewCellModel {
114 | typealias Cell = SimpleTextRowCell
115 |
116 | let text: String
117 | }
118 |
119 | private final class SimpleTextRowCell: UITableViewCell, ManagedTableViewCell {
120 | typealias Model = SimpleTextRowModel
121 | }
122 |
123 | // Use in view controller
124 | class BasicManagedTableViewController: UIViewController {
125 | override func loadView() {
126 | view = UIContainer {
127 | $0.backgroundColor = .white
128 |
129 | ManagedTableView {
130 | $0.layout.edges == $0.parentLayout.edges
131 | $0.sections <~ Property(value: [self.makeSection()])
132 | }
133 | }
134 | }
135 |
136 | private func makeSection() -> ManagedTableViewSection {
137 | ManagedTableViewSection(
138 | rows: [
139 | SimpleTextRowModel(text: "Simple row 1"),
140 | SimpleTextRowModel(text: "Simple row 2"),
141 | SimpleTextRowModel(text: "Simple row 3"),
142 | ]
143 | )
144 | }
145 | }
146 | ```
147 |
148 | ## Layout Guide Attributes
149 |
150 | Mortar provides virtual attributes that represent multiple sub-attributes:
151 |
152 | - **Position attributes**: `left`, `right`, `top`, `bottom`, `leading`, `trailing`, `centerX`, `centerY`
153 | - **Size attributes**: `width`, `height`
154 | - **Multi-attribute guides**:
155 | - `sides`: combines leading and trailing
156 | - `caps`: combines top and bottom
157 | - `size`: combines width and height
158 | - `edges`: combines top, leading, bottom, trailing
159 | - `center`: combines centerX and centerY
160 |
161 | ## Best Practices for AI Agents
162 |
163 | 1. **Use Anonymous Views**: Create views inline without explicit naming to keep code clean and readable
164 | 2. **Leverage Layout Properties**: Use `layout`, `parentLayout`, and `referencedLayout(_:)` for constraint management
165 | 3. **Apply Reactive Patterns**: Use CombineEx integration for handling events and binding data
166 | 4. **Utilize Managed Views**: For table and collection views, use the managed view components for model-driven data binding
167 | 5. **Follow Result Builder Pattern**: Use the `@MortarAddSubviewsBuilder` syntax for creating view hierarchies
168 | 6. **Use Multi-Constraint Guides**: Take advantage of virtual attributes like `sides`, `edges`, and `center` for cleaner constraint expressions
169 |
170 | ## Common Patterns
171 |
172 | ### Creating a Simple Container View
173 | ```swift
174 | UIContainer {
175 | $0.backgroundColor = .white
176 |
177 | UILabel {
178 | $0.text = "Sample Text"
179 | $0.layout.center == $0.parentLayout.center
180 | }
181 | }
182 | ```
183 |
184 | ### Creating a Stack View with Constraints
185 | ```swift
186 | UIVStack {
187 | $0.axis = .vertical
188 | $0.spacing = 10
189 | $0.layout.edges == $0.parentLayout.edges
190 |
191 | UILabel {
192 | $0.text = "Header"
193 | $0.layout.height == 44
194 | }
195 |
196 | UILabel {
197 | $0.text = "Content"
198 | $0.layout.height == 100
199 | }
200 | }
201 | ```
202 |
203 | ### Creating a Button with Event Handling
204 | ```swift
205 | UIButton(type: .system) {
206 | $0.setTitle("Tap Me", for: .normal)
207 | $0.handleEvents(.touchUpInside) {
208 | print("Button tapped!")
209 | }
210 | }
211 | ```
212 |
213 | ## Integration with Existing UIKit Code
214 |
215 | Mortar maintains full compatibility with existing UIKit infrastructure:
216 | - All views created with Mortar are standard UIKit views
217 | - Existing view controllers and navigation patterns work unchanged
218 | - Can be mixed with traditional UIKit code in the same project
219 | - No need to rewrite existing codebases to adopt Mortar
220 |
221 | ### Import Mortar in your code:
222 |
223 | ```swift
224 | import Mortar
225 | ```
226 |
227 |
--------------------------------------------------------------------------------
/Mortar/Operators.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Operators.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | // MARK: - Constraint Constants - Single Degree
7 |
8 | /// Verifies that the degree of a given coordinate matches the expected degree.
9 | /// Emits an error to the MortarError publisher if the degree does not match.
10 | ///
11 | /// - Parameters:
12 | /// - coordinate: The MortarCoordinate to verify.
13 | /// - degree: The expected degree value.
14 | private func verifyDegrees(_ coordinate: MortarCoordinate, _ degree: Int) {
15 | if coordinate.attribute?.degree != degree {
16 | MortarError.emit("Operator used with incompatible degrees: coordinate for $coordinate.attribute ?? .notAnAttribute) has degree $coordinate.attribute?.degree ?? 0) but was expecting degree $degree)")
17 | }
18 | }
19 |
20 | /// Adds a MortarCGFloatable value to the constant of a MortarCoordinate.
21 | ///
22 | /// - Parameters:
23 | /// - lhs: The left-hand side MortarCoordinate.
24 | /// - rhs: The right-hand side MortarCGFloatable value to add.
25 | /// - Returns: A new MortarCoordinate with the updated constant.
26 | public func + (lhs: MortarCoordinate, rhs: MortarCGFloatable) -> MortarCoordinate {
27 | verifyDegrees(lhs, 1)
28 |
29 | return .init(
30 | item: lhs.item,
31 | attribute: lhs.attribute,
32 | multiplier: lhs.multiplier,
33 | constant: (lhs.constant.0 + rhs.floatValue, lhs.constant.1, lhs.constant.2, lhs.constant.3),
34 | priority: lhs.priority
35 | )
36 | }
37 |
38 | /// Subtracts a MortarCGFloatable value from the constant of a MortarCoordinate.
39 | ///
40 | /// - Parameters:
41 | /// - lhs: The left-hand side MortarCoordinate.
42 | /// - rhs: The right-hand side MortarCGFloatable value to subtract.
43 | /// - Returns: A new MortarCoordinate with the updated constant.
44 | public func - (lhs: MortarCoordinate, rhs: MortarCGFloatable) -> MortarCoordinate {
45 | verifyDegrees(lhs, 1)
46 |
47 | return .init(
48 | item: lhs.item,
49 | attribute: lhs.attribute,
50 | multiplier: lhs.multiplier,
51 | constant: (lhs.constant.0 - rhs.floatValue, lhs.constant.1, lhs.constant.2, lhs.constant.3),
52 | priority: lhs.priority
53 | )
54 | }
55 |
56 | // MARK: - Constraint Constants - CGPoint
57 |
58 | public func + (lhs: MortarCoordinate, rhs: CGPoint) -> MortarCoordinate {
59 | verifyDegrees(lhs, 2)
60 |
61 | return .init(
62 | item: lhs.item,
63 | attribute: lhs.attribute,
64 | multiplier: lhs.multiplier,
65 | constant: (lhs.constant.0 + rhs.y.floatValue, lhs.constant.1 + rhs.x.floatValue, lhs.constant.2, lhs.constant.3),
66 | priority: lhs.priority
67 | )
68 | }
69 |
70 | public func - (lhs: MortarCoordinate, rhs: CGPoint) -> MortarCoordinate {
71 | verifyDegrees(lhs, 2)
72 |
73 | return .init(
74 | item: lhs.item,
75 | attribute: lhs.attribute,
76 | multiplier: lhs.multiplier,
77 | constant: (lhs.constant.0 - rhs.y.floatValue, lhs.constant.1 - rhs.x.floatValue, lhs.constant.2, lhs.constant.3),
78 | priority: lhs.priority
79 | )
80 | }
81 |
82 | // MARK: - Constraint Constants - CGSize
83 |
84 | public func + (lhs: MortarCoordinate, rhs: CGSize) -> MortarCoordinate {
85 | verifyDegrees(lhs, 2)
86 |
87 | return .init(
88 | item: lhs.item,
89 | attribute: lhs.attribute,
90 | multiplier: lhs.multiplier,
91 | constant: (lhs.constant.0 + rhs.width.floatValue, lhs.constant.1 + rhs.height.floatValue, lhs.constant.2, lhs.constant.3),
92 | priority: lhs.priority
93 | )
94 | }
95 |
96 | public func - (lhs: MortarCoordinate, rhs: CGSize) -> MortarCoordinate {
97 | verifyDegrees(lhs, 2)
98 |
99 | return .init(
100 | item: lhs.item,
101 | attribute: lhs.attribute,
102 | multiplier: lhs.multiplier,
103 | constant: (lhs.constant.0 - rhs.width.floatValue, lhs.constant.1 - rhs.height.floatValue, lhs.constant.2, lhs.constant.3),
104 | priority: lhs.priority
105 | )
106 | }
107 |
108 | // MARK: - Constraint Constants - Edge Insets
109 |
110 | public func + (lhs: MortarCoordinate, rhs: MortarEdgeInsets) -> MortarCoordinate {
111 | verifyDegrees(lhs, 4)
112 |
113 | return .init(
114 | item: lhs.item,
115 | attribute: lhs.attribute,
116 | multiplier: lhs.multiplier,
117 | constant: (
118 | lhs.constant.0 + rhs.top,
119 | lhs.constant.1 + rhs.left,
120 | lhs.constant.2 - rhs.bottom,
121 | lhs.constant.3 - rhs.right
122 | ),
123 | priority: lhs.priority
124 | )
125 | }
126 |
127 | public func - (lhs: MortarCoordinate, rhs: MortarEdgeInsets) -> MortarCoordinate {
128 | verifyDegrees(lhs, 4)
129 |
130 | return .init(
131 | item: lhs.item,
132 | attribute: lhs.attribute,
133 | multiplier: lhs.multiplier,
134 | constant: (
135 | lhs.constant.0 - rhs.top,
136 | lhs.constant.1 - rhs.left,
137 | lhs.constant.2 + rhs.bottom,
138 | lhs.constant.3 + rhs.right
139 | ),
140 | priority: lhs.priority
141 | )
142 | }
143 |
144 | // MARK: - Constraint Multipliers
145 |
146 | public func * (lhs: MortarCoordinate, rhs: MortarCGFloatable) -> MortarCoordinate {
147 | .init(
148 | item: lhs.item,
149 | attribute: lhs.attribute,
150 | multiplier: lhs.multiplier * rhs.floatValue,
151 | constant: lhs.constant,
152 | priority: lhs.priority
153 | )
154 | }
155 |
156 | public func / (lhs: MortarCoordinate, rhs: MortarCGFloatable) -> MortarCoordinate {
157 | .init(
158 | item: lhs.item,
159 | attribute: lhs.attribute,
160 | multiplier: lhs.multiplier / rhs.floatValue,
161 | constant: lhs.constant,
162 | priority: lhs.priority
163 | )
164 | }
165 |
166 | // MARK: - Priority Operator
167 |
168 | public func ^ (lhs: MortarCoordinate, rhs: MortarLayoutPriority) -> MortarCoordinate {
169 | .init(
170 | item: lhs.item,
171 | attribute: lhs.attribute,
172 | multiplier: lhs.multiplier,
173 | constant: lhs.constant,
174 | priority: rhs
175 | )
176 | }
177 |
178 | public func ^ (lhs: MortarCoordinateConvertible, rhs: MortarLayoutPriority) -> MortarCoordinate {
179 | let coord = lhs.coordinate
180 | return .init(
181 | item: coord.item,
182 | attribute: coord.attribute,
183 | multiplier: coord.multiplier,
184 | constant: coord.constant,
185 | priority: rhs
186 | )
187 | }
188 |
189 | // MARK: - Activation Operator
190 |
191 | public func % (lhs: MortarCoordinate, rhs: MortarActivationState) -> MortarCoordinate {
192 | .init(
193 | item: lhs.item,
194 | attribute: lhs.attribute,
195 | multiplier: lhs.multiplier,
196 | constant: lhs.constant,
197 | priority: lhs.priority,
198 | startActivated: rhs == .activated
199 | )
200 | }
201 |
202 | public func % (lhs: MortarCoordinateConvertible, rhs: MortarActivationState) -> MortarCoordinate {
203 | let coord = lhs.coordinate
204 | return .init(
205 | item: coord.item,
206 | attribute: coord.attribute,
207 | multiplier: coord.multiplier,
208 | constant: coord.constant,
209 | priority: coord.priority,
210 | startActivated: rhs == .activated
211 | )
212 | }
213 |
214 | // MARK: - Relation Operators
215 |
216 | public func == (lhs: MortarCoordinate, rhs: MortarCoordinate) -> MortarConstraintGroup {
217 | .init(
218 | target: lhs,
219 | source: rhs,
220 | relation: .equal
221 | )
222 | }
223 |
224 | public func >= (lhs: MortarCoordinate, rhs: MortarCoordinate) -> MortarConstraintGroup {
225 | .init(
226 | target: lhs,
227 | source: rhs,
228 | relation: .greaterThanOrEqual
229 | )
230 | }
231 |
232 | public func <= (lhs: MortarCoordinate, rhs: MortarCoordinate) -> MortarConstraintGroup {
233 | .init(
234 | target: lhs,
235 | source: rhs,
236 | relation: .lessThanOrEqual
237 | )
238 | }
239 |
240 | public func == (lhs: MortarCoordinate, rhs: MortarCoordinateConvertible) -> MortarConstraintGroup {
241 | .init(
242 | target: lhs,
243 | source: rhs.coordinate,
244 | relation: .equal
245 | )
246 | }
247 |
248 | public func >= (lhs: MortarCoordinate, rhs: MortarCoordinateConvertible) -> MortarConstraintGroup {
249 | .init(
250 | target: lhs,
251 | source: rhs.coordinate,
252 | relation: .greaterThanOrEqual
253 | )
254 | }
255 |
256 | public func <= (lhs: MortarCoordinate, rhs: MortarCoordinateConvertible) -> MortarConstraintGroup {
257 | .init(
258 | target: lhs,
259 | source: rhs.coordinate,
260 | relation: .lessThanOrEqual
261 | )
262 | }
263 |
--------------------------------------------------------------------------------
/Mortar/ManagedViews/TableView/ManagedTableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedTableView.swift
3 | // Copyright © 2025 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 |
8 | import UIKit
9 |
10 | public final class ManagedTableView: UITableView {
11 | public var sections: [ManagedTableViewSection] = [] {
12 | didSet {
13 | updateDataSource()
14 | }
15 | }
16 |
17 | public var singleSectionRows: [any ManagedTableViewCellModel] {
18 | get {
19 | sections.first?.rows ?? []
20 | }
21 |
22 | set {
23 | sections = [ManagedTableViewSection(id: "SingleSection", rows: newValue)]
24 | }
25 | }
26 |
27 | private var diffableDataSource: UITableViewDiffableDataSource?
28 | private var registeredCellIdentifiers: Set = []
29 | private var registeredHeaderIdentifiers: Set = []
30 |
31 | // Scroll Delegation
32 | let scrollDelegateHandler = _ManagedTableViewScrollDelegateHandler()
33 |
34 | // Can be observed to see when the data source/table updates
35 | @objc public private(set) dynamic var lastDataSourceUpdate: Date?
36 |
37 | // Operations
38 | public enum Operation {
39 | case reloadAllCells
40 | case reloadCell(id: String, animated: Bool)
41 | case scrollCell(id: String, to: UITableView.ScrollPosition, animated: Bool)
42 | }
43 |
44 | private let operationQueue = DispatchQueue(label: "ManagedTableView.operationQueue")
45 | private var operations: [Operation] = []
46 |
47 | public var enableHeightCaching: Bool = false
48 | fileprivate var cachedHeightsById: [String: CGFloat] = [:]
49 |
50 | override public init(frame: CGRect, style: UITableView.Style) {
51 | super.init(frame: frame, style: style)
52 |
53 | self.sectionHeaderTopPadding = 0
54 | self.delegate = self
55 |
56 | self.diffableDataSource = UITableViewDiffableDataSource(tableView: self) { [weak self] _, indexPath, _ in
57 | guard let self else {
58 | return nil
59 | }
60 | let viewModel = sections[indexPath.section].rows[indexPath.row]
61 | return viewModel.__dequeueCell(self, indexPath)
62 | }
63 | }
64 |
65 | @available(*, unavailable)
66 | required init?(coder: NSCoder) {
67 | fatalError("init(coder:) has not been implemented")
68 | }
69 |
70 | private func updateDataSource() {
71 | var snapshot = NSDiffableDataSourceSnapshot()
72 | for section in sections {
73 | snapshot.appendSections([section.id])
74 | snapshot.appendItems(section.rows.map { $0.id as String })
75 | }
76 | diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: { [weak self] in
77 | self?.lastDataSourceUpdate = Date()
78 | })
79 | }
80 |
81 | public func queueOperation(_ operation: Operation) {
82 | if Thread.isMainThread {
83 | operationQueue.sync {
84 | executeOperation(operation)
85 | }
86 | } else {
87 | operationQueue.sync {
88 | operations.append(operation)
89 | }
90 | RunLoop.main.perform { [weak self] in
91 | self?.processOperationsOnMainThread()
92 | }
93 | }
94 | }
95 |
96 | public func deferredOperationFuture(_ operation: Operation) -> AnyDeferredFuture {
97 | AnyDeferredFuture { [weak self] promise in
98 | guard let self else {
99 | promise(.success(()))
100 | return
101 | }
102 |
103 | if Thread.isMainThread {
104 | executeOperation(operation, completion: { promise(.success(())) })
105 | } else {
106 | DispatchQueue.main.async {
107 | self.executeOperation(operation, completion: { promise(.success(())) })
108 | }
109 | }
110 | }
111 | }
112 | }
113 |
114 | extension ManagedTableView: UITableViewDelegate {
115 | public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
116 | sections[section].header != nil ? UITableView.automaticDimension : 0.0
117 | }
118 |
119 | public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
120 | guard enableHeightCaching else {
121 | return UITableView.automaticDimension
122 | }
123 |
124 | let viewModel = sections[indexPath.section].rows[indexPath.row]
125 | if !viewModel.preventHeightCaching, let cachedHeight = cachedHeightsById[viewModel.id] {
126 | return cachedHeight
127 | }
128 |
129 | return UITableView.automaticDimension
130 | }
131 |
132 | public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
133 | sections[section].footer != nil ? UITableView.automaticDimension : 0.0
134 | }
135 |
136 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
137 | tableView.deselectRow(at: indexPath, animated: true)
138 | sections[indexPath.section].rows[indexPath.row].onSelect?(tableView as! ManagedTableView, indexPath)
139 | }
140 |
141 | public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
142 | sections[section].header.flatMap {
143 | $0.__dequeueHeaderFooter(self)
144 | }
145 | }
146 |
147 | public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
148 | sections[section].footer.flatMap {
149 | $0.__dequeueHeaderFooter(self)
150 | }
151 | }
152 |
153 | public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
154 | guard enableHeightCaching else {
155 | return
156 | }
157 |
158 | let viewModel = sections[indexPath.section].rows[indexPath.row]
159 | if !viewModel.preventHeightCaching {
160 | cachedHeightsById[viewModel.id] = cell.frame.size.height
161 | }
162 | }
163 | }
164 |
165 | private extension ManagedTableView {
166 | func dequeueCell(_ type: T.Type, for indexPath: IndexPath) -> T where T: ClassReusable {
167 | registerCellIfNeeded(type)
168 | return dequeueReusableCell(withIdentifier: type.typeReuseIdentifier, for: indexPath) as! T
169 | }
170 |
171 | func dequeueHeaderFooterView(_ type: T.Type) -> T where T: ClassReusable {
172 | registerHeaderFooterIfNeeded(type)
173 | return dequeueReusableHeaderFooterView(withIdentifier: type.typeReuseIdentifier) as! T
174 | }
175 | }
176 |
177 | private extension ManagedTableView {
178 | func registerCellIfNeeded(_ type: (some ClassReusable).Type) {
179 | guard !registeredCellIdentifiers.contains(type.typeReuseIdentifier) else { return }
180 | register(type.self, forCellReuseIdentifier: type.typeReuseIdentifier)
181 | registeredCellIdentifiers.insert(type.typeReuseIdentifier)
182 | }
183 |
184 | func registerHeaderFooterIfNeeded(_ type: (some ClassReusable).Type) {
185 | guard !registeredHeaderIdentifiers.contains(type.typeReuseIdentifier) else { return }
186 | register(type.self, forHeaderFooterViewReuseIdentifier: type.typeReuseIdentifier)
187 | registeredHeaderIdentifiers.insert(type.typeReuseIdentifier)
188 | }
189 | }
190 |
191 | @MainActor
192 | private extension ManagedTableViewCellModel {
193 | func __dequeueCell(_ tableView: ManagedTableView, _ indexPath: IndexPath) -> Cell {
194 | let cell = tableView.dequeueCell(Cell.self, for: indexPath)
195 | cell.update(model: self as! Cell.Model)
196 | cell.selectionStyle = .none
197 | return cell
198 | }
199 | }
200 |
201 | @MainActor
202 | private extension ManagedTableViewHeaderFooterViewModel {
203 | func __dequeueHeaderFooter(_ tableView: ManagedTableView) -> Header {
204 | let headerFooter = tableView.dequeueHeaderFooterView(Header.self)
205 | headerFooter.update(model: self as! Header.Model)
206 | return headerFooter
207 | }
208 | }
209 |
210 | // MARK: Reusable
211 |
212 | /// This internal protocol automates reuse identification for managed cells
213 | /// so that they simply use their class name as the reuse identifier.
214 | private protocol ClassReusable: AnyObject {
215 | static var typeReuseIdentifier: String { get }
216 | }
217 |
218 | extension ClassReusable {
219 | static var typeReuseIdentifier: String {
220 | String(describing: self)
221 | }
222 | }
223 |
224 | extension UITableViewHeaderFooterView: ClassReusable {}
225 | extension UITableViewCell: ClassReusable {}
226 |
227 | // MARK: Operations
228 |
229 | private extension ManagedTableView {
230 | func processOperationsOnMainThread() {
231 | operationQueue.sync {
232 | operations.forEach { executeOperation($0, completion: nil) }
233 | operations.removeAll()
234 | }
235 | }
236 |
237 | func executeOperation(_ operation: Operation, completion: (() -> Void)? = nil) {
238 | switch operation {
239 | case .reloadAllCells:
240 | guard var snapshot = diffableDataSource?.snapshot() else {
241 | return
242 | }
243 | snapshot.reloadItems(snapshot.itemIdentifiers)
244 | diffableDataSource?.applySnapshotUsingReloadData(snapshot) { [weak self] in
245 | completion?()
246 | self?.lastDataSourceUpdate = Date()
247 | }
248 | case let .reloadCell(id, animated):
249 | guard indexPathForId(id) != nil, var snapshot = diffableDataSource?.snapshot() else {
250 | return
251 | }
252 | snapshot.reloadItems([id])
253 | diffableDataSource?.apply(snapshot, animatingDifferences: animated) { [weak self] in
254 | completion?()
255 | self?.lastDataSourceUpdate = Date()
256 | }
257 | case let .scrollCell(id, position, animated):
258 | guard let indexPath = indexPathForId(id) else {
259 | return
260 | }
261 | scrollToRow(at: indexPath, at: position, animated: animated)
262 | if let completion {
263 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: completion)
264 | }
265 | }
266 | }
267 |
268 | func indexPathForId(_ id: String) -> IndexPath? {
269 | for section in sections.enumerated() {
270 | for row in section.element.rows.enumerated() {
271 | if row.element.id == id {
272 | return IndexPath(row: row.offset, section: section.offset)
273 | }
274 | }
275 | }
276 | return nil
277 | }
278 | }
279 |
280 | #endif
281 |
--------------------------------------------------------------------------------
/Examples/MortarDemo/MortarDemo.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 77;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 35AD3C372DE2A20300B21B94 /* Mortar in Frameworks */ = {isa = PBXBuildFile; productRef = 35AD3C362DE2A20300B21B94 /* Mortar */; };
11 | /* End PBXBuildFile section */
12 |
13 | /* Begin PBXFileReference section */
14 | 35AD3C1D2DE2A1A200B21B94 /* MortarDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MortarDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
15 | /* End PBXFileReference section */
16 |
17 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
18 | 35AD3C2F2DE2A1A300B21B94 /* Exceptions for "MortarDemo" folder in "MortarDemo" target */ = {
19 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
20 | membershipExceptions = (
21 | Info.plist,
22 | );
23 | target = 35AD3C1C2DE2A1A200B21B94 /* MortarDemo */;
24 | };
25 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
26 |
27 | /* Begin PBXFileSystemSynchronizedRootGroup section */
28 | 35AD3C1F2DE2A1A200B21B94 /* MortarDemo */ = {
29 | isa = PBXFileSystemSynchronizedRootGroup;
30 | exceptions = (
31 | 35AD3C2F2DE2A1A300B21B94 /* Exceptions for "MortarDemo" folder in "MortarDemo" target */,
32 | );
33 | path = MortarDemo;
34 | sourceTree = "";
35 | };
36 | /* End PBXFileSystemSynchronizedRootGroup section */
37 |
38 | /* Begin PBXFrameworksBuildPhase section */
39 | 35AD3C1A2DE2A1A200B21B94 /* Frameworks */ = {
40 | isa = PBXFrameworksBuildPhase;
41 | buildActionMask = 2147483647;
42 | files = (
43 | 35AD3C372DE2A20300B21B94 /* Mortar in Frameworks */,
44 | );
45 | runOnlyForDeploymentPostprocessing = 0;
46 | };
47 | /* End PBXFrameworksBuildPhase section */
48 |
49 | /* Begin PBXGroup section */
50 | 35AD3C142DE2A1A200B21B94 = {
51 | isa = PBXGroup;
52 | children = (
53 | 35AD3C1F2DE2A1A200B21B94 /* MortarDemo */,
54 | 35AD3C1E2DE2A1A200B21B94 /* Products */,
55 | );
56 | sourceTree = "";
57 | };
58 | 35AD3C1E2DE2A1A200B21B94 /* Products */ = {
59 | isa = PBXGroup;
60 | children = (
61 | 35AD3C1D2DE2A1A200B21B94 /* MortarDemo.app */,
62 | );
63 | name = Products;
64 | sourceTree = "";
65 | };
66 | /* End PBXGroup section */
67 |
68 | /* Begin PBXNativeTarget section */
69 | 35AD3C1C2DE2A1A200B21B94 /* MortarDemo */ = {
70 | isa = PBXNativeTarget;
71 | buildConfigurationList = 35AD3C302DE2A1A300B21B94 /* Build configuration list for PBXNativeTarget "MortarDemo" */;
72 | buildPhases = (
73 | 35AD3C192DE2A1A200B21B94 /* Sources */,
74 | 35AD3C1A2DE2A1A200B21B94 /* Frameworks */,
75 | 35AD3C1B2DE2A1A200B21B94 /* Resources */,
76 | );
77 | buildRules = (
78 | );
79 | dependencies = (
80 | );
81 | fileSystemSynchronizedGroups = (
82 | 35AD3C1F2DE2A1A200B21B94 /* MortarDemo */,
83 | );
84 | name = MortarDemo;
85 | packageProductDependencies = (
86 | 35AD3C362DE2A20300B21B94 /* Mortar */,
87 | );
88 | productName = MortarDemo;
89 | productReference = 35AD3C1D2DE2A1A200B21B94 /* MortarDemo.app */;
90 | productType = "com.apple.product-type.application";
91 | };
92 | /* End PBXNativeTarget section */
93 |
94 | /* Begin PBXProject section */
95 | 35AD3C152DE2A1A200B21B94 /* Project object */ = {
96 | isa = PBXProject;
97 | attributes = {
98 | BuildIndependentTargetsInParallel = 1;
99 | LastSwiftUpdateCheck = 1620;
100 | LastUpgradeCheck = 1620;
101 | TargetAttributes = {
102 | 35AD3C1C2DE2A1A200B21B94 = {
103 | CreatedOnToolsVersion = 16.2;
104 | };
105 | };
106 | };
107 | buildConfigurationList = 35AD3C182DE2A1A200B21B94 /* Build configuration list for PBXProject "MortarDemo" */;
108 | developmentRegion = en;
109 | hasScannedForEncodings = 0;
110 | knownRegions = (
111 | en,
112 | Base,
113 | );
114 | mainGroup = 35AD3C142DE2A1A200B21B94;
115 | minimizedProjectReferenceProxies = 1;
116 | packageReferences = (
117 | 35AD3C352DE2A20300B21B94 /* XCLocalSwiftPackageReference "../../../Mortar" */,
118 | );
119 | preferredProjectObjectVersion = 77;
120 | productRefGroup = 35AD3C1E2DE2A1A200B21B94 /* Products */;
121 | projectDirPath = "";
122 | projectRoot = "";
123 | targets = (
124 | 35AD3C1C2DE2A1A200B21B94 /* MortarDemo */,
125 | );
126 | };
127 | /* End PBXProject section */
128 |
129 | /* Begin PBXResourcesBuildPhase section */
130 | 35AD3C1B2DE2A1A200B21B94 /* Resources */ = {
131 | isa = PBXResourcesBuildPhase;
132 | buildActionMask = 2147483647;
133 | files = (
134 | );
135 | runOnlyForDeploymentPostprocessing = 0;
136 | };
137 | /* End PBXResourcesBuildPhase section */
138 |
139 | /* Begin PBXSourcesBuildPhase section */
140 | 35AD3C192DE2A1A200B21B94 /* Sources */ = {
141 | isa = PBXSourcesBuildPhase;
142 | buildActionMask = 2147483647;
143 | files = (
144 | );
145 | runOnlyForDeploymentPostprocessing = 0;
146 | };
147 | /* End PBXSourcesBuildPhase section */
148 |
149 | /* Begin XCBuildConfiguration section */
150 | 35AD3C312DE2A1A300B21B94 /* Debug */ = {
151 | isa = XCBuildConfiguration;
152 | buildSettings = {
153 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
154 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
155 | CODE_SIGN_STYLE = Automatic;
156 | CURRENT_PROJECT_VERSION = 1;
157 | GENERATE_INFOPLIST_FILE = YES;
158 | INFOPLIST_FILE = MortarDemo/Info.plist;
159 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
160 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
161 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
162 | LD_RUNPATH_SEARCH_PATHS = (
163 | "$(inherited)",
164 | "@executable_path/Frameworks",
165 | );
166 | MARKETING_VERSION = 1.0;
167 | PRODUCT_BUNDLE_IDENTIFIER = com.test.MortarDemo;
168 | PRODUCT_NAME = "$(TARGET_NAME)";
169 | SWIFT_EMIT_LOC_STRINGS = YES;
170 | SWIFT_VERSION = 5.0;
171 | TARGETED_DEVICE_FAMILY = "1,2";
172 | };
173 | name = Debug;
174 | };
175 | 35AD3C322DE2A1A300B21B94 /* Release */ = {
176 | isa = XCBuildConfiguration;
177 | buildSettings = {
178 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
179 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
180 | CODE_SIGN_STYLE = Automatic;
181 | CURRENT_PROJECT_VERSION = 1;
182 | GENERATE_INFOPLIST_FILE = YES;
183 | INFOPLIST_FILE = MortarDemo/Info.plist;
184 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
185 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
186 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
187 | LD_RUNPATH_SEARCH_PATHS = (
188 | "$(inherited)",
189 | "@executable_path/Frameworks",
190 | );
191 | MARKETING_VERSION = 1.0;
192 | PRODUCT_BUNDLE_IDENTIFIER = com.test.MortarDemo;
193 | PRODUCT_NAME = "$(TARGET_NAME)";
194 | SWIFT_EMIT_LOC_STRINGS = YES;
195 | SWIFT_VERSION = 5.0;
196 | TARGETED_DEVICE_FAMILY = "1,2";
197 | };
198 | name = Release;
199 | };
200 | 35AD3C332DE2A1A300B21B94 /* Debug */ = {
201 | isa = XCBuildConfiguration;
202 | buildSettings = {
203 | ALWAYS_SEARCH_USER_PATHS = NO;
204 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
205 | CLANG_ANALYZER_NONNULL = YES;
206 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
207 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
208 | CLANG_ENABLE_MODULES = YES;
209 | CLANG_ENABLE_OBJC_ARC = YES;
210 | CLANG_ENABLE_OBJC_WEAK = YES;
211 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
212 | CLANG_WARN_BOOL_CONVERSION = YES;
213 | CLANG_WARN_COMMA = YES;
214 | CLANG_WARN_CONSTANT_CONVERSION = YES;
215 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
216 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
217 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
218 | CLANG_WARN_EMPTY_BODY = YES;
219 | CLANG_WARN_ENUM_CONVERSION = YES;
220 | CLANG_WARN_INFINITE_RECURSION = YES;
221 | CLANG_WARN_INT_CONVERSION = YES;
222 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
223 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
224 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
225 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
226 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
227 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
228 | CLANG_WARN_STRICT_PROTOTYPES = YES;
229 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
230 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
231 | CLANG_WARN_UNREACHABLE_CODE = YES;
232 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
233 | COPY_PHASE_STRIP = NO;
234 | DEBUG_INFORMATION_FORMAT = dwarf;
235 | ENABLE_STRICT_OBJC_MSGSEND = YES;
236 | ENABLE_TESTABILITY = YES;
237 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
238 | GCC_C_LANGUAGE_STANDARD = gnu17;
239 | GCC_DYNAMIC_NO_PIC = NO;
240 | GCC_NO_COMMON_BLOCKS = YES;
241 | GCC_OPTIMIZATION_LEVEL = 0;
242 | GCC_PREPROCESSOR_DEFINITIONS = (
243 | "DEBUG=1",
244 | "$(inherited)",
245 | );
246 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
247 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
248 | GCC_WARN_UNDECLARED_SELECTOR = YES;
249 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
250 | GCC_WARN_UNUSED_FUNCTION = YES;
251 | GCC_WARN_UNUSED_VARIABLE = YES;
252 | IPHONEOS_DEPLOYMENT_TARGET = 18.2;
253 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
254 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
255 | MTL_FAST_MATH = YES;
256 | ONLY_ACTIVE_ARCH = YES;
257 | SDKROOT = iphoneos;
258 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
259 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
260 | };
261 | name = Debug;
262 | };
263 | 35AD3C342DE2A1A300B21B94 /* Release */ = {
264 | isa = XCBuildConfiguration;
265 | buildSettings = {
266 | ALWAYS_SEARCH_USER_PATHS = NO;
267 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
268 | CLANG_ANALYZER_NONNULL = YES;
269 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
270 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
271 | CLANG_ENABLE_MODULES = YES;
272 | CLANG_ENABLE_OBJC_ARC = YES;
273 | CLANG_ENABLE_OBJC_WEAK = YES;
274 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
275 | CLANG_WARN_BOOL_CONVERSION = YES;
276 | CLANG_WARN_COMMA = YES;
277 | CLANG_WARN_CONSTANT_CONVERSION = YES;
278 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
279 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
280 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
281 | CLANG_WARN_EMPTY_BODY = YES;
282 | CLANG_WARN_ENUM_CONVERSION = YES;
283 | CLANG_WARN_INFINITE_RECURSION = YES;
284 | CLANG_WARN_INT_CONVERSION = YES;
285 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
286 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
287 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
288 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
289 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
290 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
291 | CLANG_WARN_STRICT_PROTOTYPES = YES;
292 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
293 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
294 | CLANG_WARN_UNREACHABLE_CODE = YES;
295 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
296 | COPY_PHASE_STRIP = NO;
297 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
298 | ENABLE_NS_ASSERTIONS = NO;
299 | ENABLE_STRICT_OBJC_MSGSEND = YES;
300 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
301 | GCC_C_LANGUAGE_STANDARD = gnu17;
302 | GCC_NO_COMMON_BLOCKS = YES;
303 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
304 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
305 | GCC_WARN_UNDECLARED_SELECTOR = YES;
306 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
307 | GCC_WARN_UNUSED_FUNCTION = YES;
308 | GCC_WARN_UNUSED_VARIABLE = YES;
309 | IPHONEOS_DEPLOYMENT_TARGET = 18.2;
310 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
311 | MTL_ENABLE_DEBUG_INFO = NO;
312 | MTL_FAST_MATH = YES;
313 | SDKROOT = iphoneos;
314 | SWIFT_COMPILATION_MODE = wholemodule;
315 | VALIDATE_PRODUCT = YES;
316 | };
317 | name = Release;
318 | };
319 | /* End XCBuildConfiguration section */
320 |
321 | /* Begin XCConfigurationList section */
322 | 35AD3C182DE2A1A200B21B94 /* Build configuration list for PBXProject "MortarDemo" */ = {
323 | isa = XCConfigurationList;
324 | buildConfigurations = (
325 | 35AD3C332DE2A1A300B21B94 /* Debug */,
326 | 35AD3C342DE2A1A300B21B94 /* Release */,
327 | );
328 | defaultConfigurationIsVisible = 0;
329 | defaultConfigurationName = Release;
330 | };
331 | 35AD3C302DE2A1A300B21B94 /* Build configuration list for PBXNativeTarget "MortarDemo" */ = {
332 | isa = XCConfigurationList;
333 | buildConfigurations = (
334 | 35AD3C312DE2A1A300B21B94 /* Debug */,
335 | 35AD3C322DE2A1A300B21B94 /* Release */,
336 | );
337 | defaultConfigurationIsVisible = 0;
338 | defaultConfigurationName = Release;
339 | };
340 | /* End XCConfigurationList section */
341 |
342 | /* Begin XCLocalSwiftPackageReference section */
343 | 35AD3C352DE2A20300B21B94 /* XCLocalSwiftPackageReference "../../../Mortar" */ = {
344 | isa = XCLocalSwiftPackageReference;
345 | relativePath = ../../../Mortar;
346 | };
347 | /* End XCLocalSwiftPackageReference section */
348 |
349 | /* Begin XCSwiftPackageProductDependency section */
350 | 35AD3C362DE2A20300B21B94 /* Mortar */ = {
351 | isa = XCSwiftPackageProductDependency;
352 | productName = Mortar;
353 | };
354 | /* End XCSwiftPackageProductDependency section */
355 | };
356 | rootObject = 35AD3C152DE2A1A200B21B94 /* Project object */;
357 | }
358 |
--------------------------------------------------------------------------------
/Mortar/Mortar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Mortar.swift
3 | // Copyright © 2016 Jason Fieldman.
4 | //
5 |
6 | #if os(iOS) || os(tvOS)
7 | import UIKit
8 |
9 | public typealias MortarView = UIView
10 | public typealias MortarStackView = UIStackView
11 | public typealias MortarLayoutGuide = UILayoutGuide
12 | public typealias MortarEdgeInsets = UIEdgeInsets
13 | public typealias MortarAliasLayoutPriority = UILayoutPriority
14 | public typealias MortarAliasLayoutRelation = NSLayoutConstraint.Relation
15 | public typealias MortarAliasLayoutAttribute = NSLayoutConstraint.Attribute
16 | public typealias MortarAliasLayoutAxis = NSLayoutConstraint.Axis
17 | public typealias MortarGestureRecognizer = UIGestureRecognizer
18 | public typealias MortarGestureRecognizerDelegate = UIGestureRecognizerDelegate
19 | #else
20 | import AppKit
21 |
22 | public typealias MortarView = NSView
23 | public typealias MortarStackView = NSStackView
24 | public typealias MortarLayoutGuide = NSLayoutGuide
25 | public typealias MortarEdgeInsets = NSEdgeInsets
26 | public typealias MortarAliasLayoutPriority = NSLayoutConstraint.Priority
27 | public typealias MortarAliasLayoutRelation = NSLayoutConstraint.Relation
28 | public typealias MortarAliasLayoutAttribute = NSLayoutConstraint.Attribute
29 | public typealias MortarAliasLayoutAxis = NSLayoutConstraint.Orientation
30 | public typealias MortarGestureRecognizer = NSGestureRecognizer
31 | public typealias MortarGestureRecognizerDelegate = NSGestureRecognizerDelegate
32 | #endif
33 |
34 | public enum MortarActivationState {
35 | case activated, deactivated
36 | }
37 |
38 | /// Defines the virtual Mortar-specific attributes, which allow for custom
39 | /// attributes that represent multuple sub-attributes.
40 | enum MortarLayoutAttribute {
41 | enum LayoutType {
42 | case position, size
43 | }
44 |
45 | /// How many sub-attributes exist in this attribute
46 | var degree: Int { componentAttributes.count }
47 |
48 | // Standard attributes
49 | case left
50 | case right
51 | case top
52 | case bottom
53 | case leading
54 | case trailing
55 | case width
56 | case height
57 | case centerX
58 | case centerY
59 | case baseline
60 | case firstBaseline
61 | case lastBaseline
62 | case notAnAttribute
63 |
64 | // iOS/tvOS-specific attributes
65 | #if os(iOS) || os(tvOS)
66 | case leftMargin
67 | case rightMargin
68 | case topMargin
69 | case bottomMargin
70 | case leadingMargin
71 | case trailingMargin
72 | case centerXWithinMargins
73 | case centerYWithinMargins
74 | case sideMargins
75 | case capMargins
76 | case edgeMargins
77 | #endif
78 |
79 | // Attributes with multiple sub-attributes
80 | case sides
81 | case caps
82 | case size
83 | case topLeft
84 | case topLeading
85 | case topRight
86 | case topTrailing
87 | case bottomLeft
88 | case bottomLeading
89 | case bottomRight
90 | case bottomTrailing
91 | case edges
92 | case center
93 |
94 | #if os(iOS) || os(tvOS)
95 | static func from(_ standardAttribute: MortarAliasLayoutAttribute) -> MortarLayoutAttribute {
96 | switch standardAttribute {
97 | case .left: return .left
98 | case .right: return .right
99 | case .top: return .top
100 | case .bottom: return .bottom
101 | case .leading: return .leading
102 | case .trailing: return .trailing
103 | case .width: return .width
104 | case .height: return .height
105 | case .centerX: return .centerX
106 | case .centerY: return .centerY
107 | case .lastBaseline: return .lastBaseline
108 | case .firstBaseline: return .firstBaseline
109 | case .leftMargin: return .leftMargin
110 | case .rightMargin: return .rightMargin
111 | case .topMargin: return .topMargin
112 | case .bottomMargin: return .bottomMargin
113 | case .leadingMargin: return .leadingMargin
114 | case .trailingMargin: return .trailingMargin
115 | case .centerXWithinMargins: return .centerXWithinMargins
116 | case .centerYWithinMargins: return .centerYWithinMargins
117 | case .notAnAttribute: return .notAnAttribute
118 | @unknown default:
119 | return .notAnAttribute
120 | }
121 | }
122 | #else
123 | static func from(_ standardAttribute: MortarAliasLayoutAttribute) -> MortarLayoutAttribute {
124 | switch standardAttribute {
125 | case .left: return .left
126 | case .right: return .right
127 | case .top: return .top
128 | case .bottom: return .bottom
129 | case .leading: return .leading
130 | case .trailing: return .trailing
131 | case .width: return .width
132 | case .height: return .height
133 | case .centerX: return .centerX
134 | case .centerY: return .centerY
135 | case .lastBaseline: return .lastBaseline
136 | case .firstBaseline: return .firstBaseline
137 | case .notAnAttribute: return .notAnAttribute
138 | @unknown default:
139 | return .notAnAttribute
140 | }
141 | }
142 | #endif
143 |
144 | #if os(iOS) || os(tvOS)
145 | var standardLayoutAttribute: MortarAliasLayoutAttribute? {
146 | switch self {
147 | case .left: .left
148 | case .right: .right
149 | case .top: .top
150 | case .bottom: .bottom
151 | case .leading: .leading
152 | case .trailing: .trailing
153 | case .width: .width
154 | case .height: .height
155 | case .centerX: .centerX
156 | case .centerY: .centerY
157 | case .baseline: .lastBaseline
158 | case .firstBaseline: .firstBaseline
159 | case .lastBaseline: .lastBaseline
160 | case .leftMargin: .leftMargin
161 | case .rightMargin: .rightMargin
162 | case .topMargin: .topMargin
163 | case .bottomMargin: .bottomMargin
164 | case .leadingMargin: .leadingMargin
165 | case .trailingMargin: .trailingMargin
166 | case .centerXWithinMargins: .centerXWithinMargins
167 | case .centerYWithinMargins: .centerYWithinMargins
168 | case .notAnAttribute: .notAnAttribute
169 | default: nil
170 | }
171 | }
172 | #else
173 | var standardLayoutAttribute: MortarAliasLayoutAttribute? {
174 | switch self {
175 | case .left: .left
176 | case .right: .right
177 | case .top: .top
178 | case .bottom: .bottom
179 | case .leading: .leading
180 | case .trailing: .trailing
181 | case .width: .width
182 | case .height: .height
183 | case .centerX: .centerX
184 | case .centerY: .centerY
185 | case .baseline: .lastBaseline
186 | case .firstBaseline: .firstBaseline
187 | case .lastBaseline: .lastBaseline
188 | case .notAnAttribute: .notAnAttribute
189 | default: nil
190 | }
191 | }
192 | #endif
193 |
194 | #if os(iOS) || os(tvOS)
195 | var axis: MortarAliasLayoutAxis? {
196 | switch self {
197 | case .left: .horizontal
198 | case .right: .horizontal
199 | case .top: .vertical
200 | case .bottom: .vertical
201 | case .leading: .horizontal
202 | case .trailing: .horizontal
203 | case .width: .horizontal
204 | case .height: .vertical
205 | case .centerX: .horizontal
206 | case .centerY: .vertical
207 | case .baseline: .vertical
208 | case .firstBaseline: .vertical
209 | case .lastBaseline: .vertical
210 | case .leftMargin: .horizontal
211 | case .rightMargin: .horizontal
212 | case .topMargin: .vertical
213 | case .bottomMargin: .vertical
214 | case .leadingMargin: .horizontal
215 | case .trailingMargin: .horizontal
216 | case .centerXWithinMargins: .horizontal
217 | case .centerYWithinMargins: .vertical
218 | case .sideMargins: .horizontal
219 | case .capMargins: .vertical
220 | default: nil
221 | }
222 | }
223 | #else
224 | var axis: MortarAliasLayoutAxis? {
225 | switch self {
226 | case .left: .horizontal
227 | case .right: .horizontal
228 | case .top: .vertical
229 | case .bottom: .vertical
230 | case .leading: .horizontal
231 | case .trailing: .horizontal
232 | case .width: .horizontal
233 | case .height: .vertical
234 | case .centerX: .horizontal
235 | case .centerY: .vertical
236 | case .baseline: .vertical
237 | case .firstBaseline: .vertical
238 | case .lastBaseline: .vertical
239 | default: nil
240 | }
241 | }
242 | #endif
243 |
244 | #if os(iOS) || os(tvOS)
245 | var layoutType: LayoutType? {
246 | switch self {
247 | case .left: .position
248 | case .right: .position
249 | case .top: .position
250 | case .bottom: .position
251 | case .leading: .position
252 | case .trailing: .position
253 | case .width: .size
254 | case .height: .size
255 | case .centerX: .position
256 | case .centerY: .position
257 | case .baseline: .position
258 | case .firstBaseline: .position
259 | case .lastBaseline: .position
260 | case .leftMargin: .position
261 | case .rightMargin: .position
262 | case .topMargin: .position
263 | case .bottomMargin: .position
264 | case .leadingMargin: .position
265 | case .trailingMargin: .position
266 | case .centerXWithinMargins: .position
267 | case .centerYWithinMargins: .position
268 | case .sideMargins: .position
269 | case .capMargins: .position
270 | default: nil
271 | }
272 | }
273 | #else
274 | var layoutType: LayoutType? {
275 | switch self {
276 | case .left: .position
277 | case .right: .position
278 | case .top: .position
279 | case .bottom: .position
280 | case .leading: .position
281 | case .trailing: .position
282 | case .width: .size
283 | case .height: .size
284 | case .centerX: .position
285 | case .centerY: .position
286 | case .baseline: .position
287 | case .firstBaseline: .position
288 | case .lastBaseline: .position
289 | default: nil
290 | }
291 | }
292 | #endif
293 |
294 | #if os(iOS) || os(tvOS)
295 | var componentAttributes: [MortarAliasLayoutAttribute] {
296 | switch self {
297 | case .left: [.left]
298 | case .right: [.right]
299 | case .top: [.top]
300 | case .bottom: [.bottom]
301 | case .leading: [.leading]
302 | case .trailing: [.trailing]
303 | case .width: [.width]
304 | case .height: [.height]
305 | case .centerX: [.centerX]
306 | case .centerY: [.centerY]
307 | case .baseline: [.lastBaseline]
308 | case .firstBaseline: [.firstBaseline]
309 | case .lastBaseline: [.lastBaseline]
310 | case .leftMargin: [.leftMargin]
311 | case .rightMargin: [.rightMargin]
312 | case .topMargin: [.topMargin]
313 | case .bottomMargin: [.bottomMargin]
314 | case .leadingMargin: [.leadingMargin]
315 | case .trailingMargin: [.trailingMargin]
316 | case .centerXWithinMargins: [.centerXWithinMargins]
317 | case .centerYWithinMargins: [.centerYWithinMargins]
318 | case .sideMargins: [.leadingMargin, .trailingMargin]
319 | case .capMargins: [.topMargin, .bottomMargin]
320 | case .edgeMargins: [.topMargin, .leadingMargin, .bottomMargin, .trailingMargin]
321 | case .notAnAttribute: [.notAnAttribute]
322 | case .sides: [.leading, .trailing]
323 | case .caps: [.top, .bottom]
324 | case .size: [.width, .height]
325 | case .topLeft: [.top, .left]
326 | case .topLeading: [.top, .leading]
327 | case .topRight: [.top, .right]
328 | case .topTrailing: [.top, .trailing]
329 | case .bottomLeft: [.bottom, .left]
330 | case .bottomLeading: [.bottom, .leading]
331 | case .bottomRight: [.bottom, .right]
332 | case .bottomTrailing: [.bottom, .trailing]
333 | case .edges: [.top, .leading, .bottom, .trailing]
334 | case .center: [.centerX, .centerY]
335 | }
336 | }
337 | #else
338 | var componentAttributes: [MortarAliasLayoutAttribute] {
339 | switch self {
340 | case .left: [.left]
341 | case .right: [.right]
342 | case .top: [.top]
343 | case .bottom: [.bottom]
344 | case .leading: [.leading]
345 | case .trailing: [.trailing]
346 | case .width: [.width]
347 | case .height: [.height]
348 | case .centerX: [.centerX]
349 | case .centerY: [.centerY]
350 | case .baseline: [.lastBaseline]
351 | case .firstBaseline: [.firstBaseline]
352 | case .lastBaseline: [.lastBaseline]
353 | case .notAnAttribute: [.notAnAttribute]
354 | case .sides: [.leading, .trailing]
355 | case .caps: [.top, .bottom]
356 | case .size: [.width, .height]
357 | case .topLeft: [.top, .left]
358 | case .topLeading: [.top, .leading]
359 | case .topRight: [.top, .right]
360 | case .topTrailing: [.top, .trailing]
361 | case .bottomLeft: [.bottom, .left]
362 | case .bottomLeading: [.bottom, .leading]
363 | case .bottomRight: [.bottom, .right]
364 | case .bottomTrailing: [.bottom, .trailing]
365 | case .edges: [.top, .leading, .bottom, .trailing]
366 | case .center: [.centerX, .centerY]
367 | }
368 | }
369 | #endif
370 | }
371 |
372 | public enum MortarLayoutPriority {
373 | case low, medium, high, required, priority(Int)
374 |
375 | @inline(__always) public func layoutPriority() -> MortarAliasLayoutPriority {
376 | switch self {
377 | case .low: MortarAliasLayoutPriority.defaultLow
378 | case .medium: MortarAliasLayoutPriority(rawValue: (Float(MortarAliasLayoutPriority.defaultHigh.rawValue) + Float(MortarAliasLayoutPriority.defaultLow.rawValue)) / 2)
379 | case .high: MortarAliasLayoutPriority.defaultHigh
380 | case .required: MortarAliasLayoutPriority.required
381 | case let .priority(value): MortarAliasLayoutPriority(rawValue: Float(value))
382 | }
383 | }
384 | }
385 |
--------------------------------------------------------------------------------