diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f00c10e --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..065392a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2023 Pavel Sharanda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9799a0c --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let packageName = "FixFlex" + +let package = Package( + name: packageName, + platforms: [ + .iOS(.v12), + .macOS(.v10_13), + .tvOS(.v12), + ], + products: [ + .library( + name: packageName, + targets: [packageName] + ), + ], + dependencies: [], + targets: [ + .target( + name: packageName, + dependencies: [], + path: "Sources" + ), + .testTarget( + name: packageName + "Tests", + dependencies: [Target.Dependency(stringLiteral: packageName)] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..faf9f08 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# FixFlex diff --git a/Sources/FixFlex.swift b/Sources/FixFlex.swift new file mode 100644 index 0000000..23be827 --- /dev/null +++ b/Sources/FixFlex.swift @@ -0,0 +1,306 @@ +#if os(macOS) + import AppKit + public typealias _View = NSView + public typealias _LayoutGuide = NSLayoutGuide + public typealias _LayoutPriority = NSLayoutConstraint.Priority +#else + import UIKit + public typealias _View = UIView + public typealias _LayoutGuide = UILayoutGuide + public typealias _LayoutPriority = UILayoutPriority +#endif + +public struct FixFlexing { + let base: _View + init(_ base: _View) { + self.base = base + } +} + +public extension _View { + var fx: FixFlexing { + return FixFlexing(self) + } +} + +public struct PutIntent { + let views: [_View]? + + enum Sizing { + case fix(value: CGFloat) + case flex(min: CGFloat?, max: CGFloat?, huggingPriority: _LayoutPriority?, compressionResistancePriority: _LayoutPriority?) + case match(dimension: NSLayoutDimension, multiplier: CGFloat, offset: CGFloat) + case split(weight: CGFloat) + } + + let sizing: Sizing + + var onCreateDimensionConstraint: ((NSLayoutConstraint) -> Void)? + + public func onCreateDimensionConstraint(_ block: @escaping (NSLayoutConstraint) -> Void) -> PutIntent { + var newSelf = self + newSelf.onCreateDimensionConstraint = block + return newSelf + } +} + +/// Fix shorthands + +public func Fix(_ value: CGFloat) -> PutIntent { + return PutIntent(views: nil, sizing: .fix(value: value)) +} + +public func Fix(_ view: _View, _ value: CGFloat) -> PutIntent { + return Fix([view], value) +} + +public func Fix(_ views: [_View], _ value: CGFloat) -> PutIntent { + return PutIntent(views: views, sizing: .fix(value: value)) +} + +/// Flex shorthands + +public func Flex(min: CGFloat? = nil, max: CGFloat? = nil) -> PutIntent { + return PutIntent(views: nil, sizing: .flex(min: min, max: max, huggingPriority: .required, compressionResistancePriority: .required)) +} + +public func Flex(_ view: _View, min: CGFloat? = nil, max: CGFloat? = nil, huggingPriority: _LayoutPriority? = nil, compressionResistancePriority: _LayoutPriority? = nil) -> PutIntent { + return Flex([view], min: min, max: max, huggingPriority: huggingPriority, compressionResistancePriority: compressionResistancePriority) +} + +public func Flex(_ views: [_View], min: CGFloat? = nil, max: CGFloat? = nil, huggingPriority: _LayoutPriority? = nil, compressionResistancePriority: _LayoutPriority? = nil) -> PutIntent { + return PutIntent(views: views, sizing: .flex(min: min, max: max, huggingPriority: huggingPriority, compressionResistancePriority: compressionResistancePriority)) +} + +/// Match shorthands + +public func Match(_ dimension: NSLayoutDimension, multiplier: CGFloat = 1, offset: CGFloat = 0) -> PutIntent { + return PutIntent(views: nil, sizing: .match(dimension: dimension, multiplier: multiplier, offset: offset)) +} + +public func Match(_ view: _View, _ dimension: NSLayoutDimension, multiplier: CGFloat = 1, offset: CGFloat = 0) -> PutIntent { + return Match([view], dimension, multiplier: multiplier, offset: offset) +} + +public func Match(_ views: [_View], _ dimension: NSLayoutDimension, multiplier: CGFloat = 1, offset: CGFloat = 0) -> PutIntent { + return PutIntent(views: views, sizing: .match(dimension: dimension, multiplier: multiplier, offset: offset)) +} + +/// Split shorthands + +public func Split(_ weight: CGFloat = 1.0) -> PutIntent { + return PutIntent(views: nil, sizing: .split(weight: weight)) +} + +public func Split(_ view: _View, _ weight: CGFloat = 1.0) -> PutIntent { + return Split([view], weight) +} + +public func Split(_ views: [_View], _ weight: CGFloat = 1.0) -> PutIntent { + return PutIntent(views: views, sizing: .split(weight: weight)) +} + +/// Axis anchors abstraction + +private struct AxisAnchors { + let startAnchor: NSLayoutAnchor + let dimensionAnchor: NSLayoutDimension + let endAnchor: NSLayoutAnchor +} + +private protocol AxisAnchorsBuilder { + associatedtype AnchorType: AnyObject + func anchorsForView(_ view: _View) -> AxisAnchors + func anchorsForLayoutGuide(_ layoutGuide: _LayoutGuide) -> AxisAnchors + func setContentHuggingPriority(for view: _View, layoutPriority: _LayoutPriority) + func setContentCompressionResistancePriority(for view: _View, layoutPriority: _LayoutPriority) +} + +private struct XAxisAnchorsBuilder: AxisAnchorsBuilder { + typealias AnchorType = NSLayoutXAxisAnchor + + let useAbsolutePositioning: Bool + init(useAbsolutePositioning: Bool) { + self.useAbsolutePositioning = useAbsolutePositioning + } + + func anchorsForView(_ view: _View) -> AxisAnchors { + return AxisAnchors(startAnchor: useAbsolutePositioning ? view.leftAnchor : view.leadingAnchor, + dimensionAnchor: view.widthAnchor, + endAnchor: useAbsolutePositioning ? view.rightAnchor : view.trailingAnchor) + } + + func anchorsForLayoutGuide(_ layoutGuide: _LayoutGuide) -> AxisAnchors { + return AxisAnchors(startAnchor: useAbsolutePositioning ? layoutGuide.leftAnchor : layoutGuide.leadingAnchor, + dimensionAnchor: layoutGuide.widthAnchor, + endAnchor: useAbsolutePositioning ? layoutGuide.rightAnchor : layoutGuide.trailingAnchor) + } + + func setContentHuggingPriority(for view: _View, layoutPriority: _LayoutPriority) { + view.setContentHuggingPriority(layoutPriority, for: .horizontal) + } + + func setContentCompressionResistancePriority(for view: _View, layoutPriority: _LayoutPriority) { + view.setContentCompressionResistancePriority(layoutPriority, for: .horizontal) + } +} + +private struct YAxisAnchorsBuilder: AxisAnchorsBuilder { + typealias AnchorType = NSLayoutYAxisAnchor + + func anchorsForView(_ view: _View) -> AxisAnchors { + return AxisAnchors(startAnchor: view.topAnchor, + dimensionAnchor: view.heightAnchor, + endAnchor: view.bottomAnchor) + } + + func anchorsForLayoutGuide(_ layoutGuide: _LayoutGuide) -> AxisAnchors { + return AxisAnchors(startAnchor: layoutGuide.topAnchor, + dimensionAnchor: layoutGuide.heightAnchor, + endAnchor: layoutGuide.bottomAnchor) + } + + func setContentHuggingPriority(for view: _View, layoutPriority: _LayoutPriority) { + view.setContentHuggingPriority(layoutPriority, for: .vertical) + } + + func setContentCompressionResistancePriority(for view: _View, layoutPriority: _LayoutPriority) { + view.setContentCompressionResistancePriority(layoutPriority, for: .vertical) + } +} + +public struct PutResult { + public let constraints: [NSLayoutConstraint] + public let layoutGuides: [_LayoutGuide] +} + +public extension FixFlexing { + private func _put( + _ intents: [PutIntent], + builder: AxisAnchorsBuilderType, + startAnchor: NSLayoutAnchor, + endAnchor: NSLayoutAnchor + ) -> PutResult where AxisAnchorsBuilderType.AnchorType == AnchorType { + var lastAnchors = [startAnchor] + var weightsInfo: (dimensionAnchor: NSLayoutDimension, weight: CGFloat)? + var constraints: [NSLayoutConstraint] = [] + var layoutGuides: [_LayoutGuide] = [] + + for intent in intents { + let aas: [AxisAnchors] + + if let views = intent.views, views.count > 0 { + views.forEach { view in + view.translatesAutoresizingMaskIntoConstraints = false + } + + aas = views.map { view in + builder.anchorsForView(view) + } + } else { + let layoutGuide = _LayoutGuide() + layoutGuides.append(layoutGuide) + base.addLayoutGuide(layoutGuide) + aas = [builder.anchorsForLayoutGuide(layoutGuide)] + } + + for aa in aas { + for lastAnchor in lastAnchors { + constraints.append(aa.startAnchor.constraint(equalTo: lastAnchor)) + } + + func handleSizingConstraint(_ constraint: NSLayoutConstraint) { + constraints.append(constraint) + intent.onCreateDimensionConstraint?(constraint) + } + + switch intent.sizing { + case let .fix(value): + handleSizingConstraint(aa.dimensionAnchor.constraint(equalToConstant: value)) + case let .flex(min, max, huggingPriority, compressionResistancePriority): + + if let huggingPriority { + intent.views?.forEach { + builder.setContentHuggingPriority(for: $0, layoutPriority: huggingPriority) + } + } + + if let compressionResistancePriority { + intent.views?.forEach { + builder.setContentCompressionResistancePriority(for: $0, layoutPriority: compressionResistancePriority) + } + } + + if let min { + handleSizingConstraint(aa.dimensionAnchor.constraint(greaterThanOrEqualToConstant: min)) + } + if let max { + handleSizingConstraint(aa.dimensionAnchor.constraint(lessThanOrEqualToConstant: max)) + } + case let .match(dimension, multiplier, offset): + handleSizingConstraint(aa.dimensionAnchor.constraint(equalTo: dimension, multiplier: multiplier, constant: offset)) + case let .split(weight): + if let weightsInfo { + handleSizingConstraint(aa.dimensionAnchor.constraint(equalTo: weightsInfo.dimensionAnchor, + multiplier: weight / weightsInfo.weight)) + } else { + weightsInfo = (aa.dimensionAnchor, weight) + } + } + } + lastAnchors = aas.map { $0.endAnchor } + } + + lastAnchors.forEach { + constraints.append($0.constraint(equalTo: endAnchor)) + } + + NSLayoutConstraint.activate(constraints) + + return PutResult(constraints: constraints, layoutGuides: layoutGuides) + } + + @discardableResult + func hput( + startAnchor: NSLayoutXAxisAnchor? = nil, + endAnchor: NSLayoutXAxisAnchor? = nil, + useAbsolutePositioning: Bool = false, + _ intents: [PutIntent] + ) -> PutResult { + return _put(intents, + builder: XAxisAnchorsBuilder(useAbsolutePositioning: useAbsolutePositioning), + startAnchor: startAnchor ?? (useAbsolutePositioning ? base.leftAnchor : base.leadingAnchor), + endAnchor: endAnchor ?? (useAbsolutePositioning ? base.rightAnchor : base.trailingAnchor)) + } + + @discardableResult + func hput( + startAnchor: NSLayoutXAxisAnchor? = nil, + endAnchor: NSLayoutXAxisAnchor? = nil, + useAbsolutePositioning: Bool = false, + _ intents: PutIntent... + ) -> PutResult { + return hput(startAnchor: startAnchor, endAnchor: endAnchor, useAbsolutePositioning: useAbsolutePositioning, intents) + } + + @discardableResult + func vput( + startAnchor: NSLayoutYAxisAnchor? = nil, + endAnchor: NSLayoutYAxisAnchor? = nil, + _ intents: [PutIntent] + ) -> PutResult { + return _put(intents, + builder: YAxisAnchorsBuilder(), + startAnchor: startAnchor ?? base.topAnchor, + endAnchor: endAnchor ?? base.bottomAnchor) + } + + @discardableResult + func vput( + startAnchor: NSLayoutYAxisAnchor? = nil, + endAnchor: NSLayoutYAxisAnchor? = nil, + _ intents: PutIntent... + ) -> PutResult { + return vput(startAnchor: startAnchor, endAnchor: endAnchor, intents) + } +} diff --git a/Tests/FixFlexTests/FixFlexTests.swift b/Tests/FixFlexTests/FixFlexTests.swift new file mode 100644 index 0000000..ca5e9a1 --- /dev/null +++ b/Tests/FixFlexTests/FixFlexTests.swift @@ -0,0 +1,4 @@ +@testable import FixFlex +import XCTest + +class FixFlexTests: XCTestCase {}