├── .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 | --------------------------------------------------------------------------------