├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcuserdata │ └── tbrennan.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── Demo ├── SwiftUILayoutsDemo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── tbrennan.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── SwiftUILayoutsDemo │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── demo0.imageset │ │ ├── Contents.json │ │ └── demo0.jpeg │ ├── demo1.imageset │ │ ├── Contents.json │ │ └── demo1.jpeg │ ├── demo2.imageset │ │ ├── Contents.json │ │ └── demo2.jpeg │ ├── demo3.imageset │ │ ├── Contents.json │ │ └── demo3.jpeg │ ├── demo4.imageset │ │ ├── Contents.json │ │ └── demo4.jpeg │ ├── demo5.imageset │ │ ├── Contents.json │ │ └── demo5.jpeg │ ├── demo6.imageset │ │ ├── Contents.json │ │ └── demo6.jpeg │ └── demo7.imageset │ │ ├── Contents.json │ │ └── demo7.jpeg │ ├── ContentView.swift │ ├── FlowTestView.swift │ ├── LayoutTypes.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── SwiftUILayoutsApp.swift │ └── WaterfallTestView.swift ├── Package.swift ├── README.md └── Sources └── SwiftUILayouts ├── CompressingHStack.swift ├── EqualHStack.swift ├── EqualVStack.swift ├── FlowLayout.swift └── VerticalWaterfallLayout.swift /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/tbrennan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | SwiftUILayouts.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | SwiftUILayouts 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D74C964C2855E58C00CEE4B9 /* SwiftUILayouts in Frameworks */ = {isa = PBXBuildFile; productRef = D74C964B2855E58C00CEE4B9 /* SwiftUILayouts */; }; 11 | D74C964E2855E6B600CEE4B9 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = D74C964D2855E6B600CEE4B9 /* README.md */; }; 12 | D7A30936285486B800413565 /* SwiftUILayoutsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A30935285486B800413565 /* SwiftUILayoutsApp.swift */; }; 13 | D7A30938285486B800413565 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A30937285486B800413565 /* ContentView.swift */; }; 14 | D7A3093A285486BA00413565 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7A30939285486BA00413565 /* Assets.xcassets */; }; 15 | D7A3093D285486BA00413565 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7A3093C285486BA00413565 /* Preview Assets.xcassets */; }; 16 | D7A30944285486C900413565 /* LayoutTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A30943285486C900413565 /* LayoutTypes.swift */; }; 17 | D7A3094A28558F1E00413565 /* FlowTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A3094928558F1E00413565 /* FlowTestView.swift */; }; 18 | D7A3094C285594A500413565 /* WaterfallTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A3094B285594A500413565 /* WaterfallTestView.swift */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXFileReference section */ 22 | D74C96472855E47700CEE4B9 /* SwiftUILayouts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SwiftUILayouts; path = ..; sourceTree = ""; }; 23 | D74C964D2855E6B600CEE4B9 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 24 | D7A30932285486B800413565 /* SwiftUILayoutsDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUILayoutsDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 25 | D7A30935285486B800413565 /* SwiftUILayoutsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUILayoutsApp.swift; sourceTree = ""; }; 26 | D7A30937285486B800413565 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 27 | D7A30939285486BA00413565 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 28 | D7A3093C285486BA00413565 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 29 | D7A30943285486C900413565 /* LayoutTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutTypes.swift; sourceTree = ""; }; 30 | D7A3094928558F1E00413565 /* FlowTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowTestView.swift; sourceTree = ""; }; 31 | D7A3094B285594A500413565 /* WaterfallTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaterfallTestView.swift; sourceTree = ""; }; 32 | /* End PBXFileReference section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | D7A3092F285486B800413565 /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | D74C964C2855E58C00CEE4B9 /* SwiftUILayouts in Frameworks */, 40 | ); 41 | runOnlyForDeploymentPostprocessing = 0; 42 | }; 43 | /* End PBXFrameworksBuildPhase section */ 44 | 45 | /* Begin PBXGroup section */ 46 | D74C964A2855E58C00CEE4B9 /* Frameworks */ = { 47 | isa = PBXGroup; 48 | children = ( 49 | ); 50 | name = Frameworks; 51 | sourceTree = ""; 52 | }; 53 | D7A30929285486B800413565 = { 54 | isa = PBXGroup; 55 | children = ( 56 | D74C964D2855E6B600CEE4B9 /* README.md */, 57 | D7A3094F2855E1D200413565 /* Packages */, 58 | D7A30934285486B800413565 /* SwiftUILayoutsDemo */, 59 | D7A30933285486B800413565 /* Products */, 60 | D74C964A2855E58C00CEE4B9 /* Frameworks */, 61 | ); 62 | sourceTree = ""; 63 | }; 64 | D7A30933285486B800413565 /* Products */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | D7A30932285486B800413565 /* SwiftUILayoutsDemo.app */, 68 | ); 69 | name = Products; 70 | sourceTree = ""; 71 | }; 72 | D7A30934285486B800413565 /* SwiftUILayoutsDemo */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | D7A30935285486B800413565 /* SwiftUILayoutsApp.swift */, 76 | D7A30937285486B800413565 /* ContentView.swift */, 77 | D7A3094B285594A500413565 /* WaterfallTestView.swift */, 78 | D7A3094928558F1E00413565 /* FlowTestView.swift */, 79 | D7A30943285486C900413565 /* LayoutTypes.swift */, 80 | D7A30939285486BA00413565 /* Assets.xcassets */, 81 | D7A3093B285486BA00413565 /* Preview Content */, 82 | ); 83 | path = SwiftUILayoutsDemo; 84 | sourceTree = ""; 85 | }; 86 | D7A3093B285486BA00413565 /* Preview Content */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | D7A3093C285486BA00413565 /* Preview Assets.xcassets */, 90 | ); 91 | path = "Preview Content"; 92 | sourceTree = ""; 93 | }; 94 | D7A3094F2855E1D200413565 /* Packages */ = { 95 | isa = PBXGroup; 96 | children = ( 97 | D74C96472855E47700CEE4B9 /* SwiftUILayouts */, 98 | ); 99 | name = Packages; 100 | sourceTree = ""; 101 | }; 102 | /* End PBXGroup section */ 103 | 104 | /* Begin PBXNativeTarget section */ 105 | D7A30931285486B800413565 /* SwiftUILayoutsDemo */ = { 106 | isa = PBXNativeTarget; 107 | buildConfigurationList = D7A30940285486BA00413565 /* Build configuration list for PBXNativeTarget "SwiftUILayoutsDemo" */; 108 | buildPhases = ( 109 | D7A3092E285486B800413565 /* Sources */, 110 | D7A3092F285486B800413565 /* Frameworks */, 111 | D7A30930285486B800413565 /* Resources */, 112 | ); 113 | buildRules = ( 114 | ); 115 | dependencies = ( 116 | D74C96492855E49800CEE4B9 /* PBXTargetDependency */, 117 | ); 118 | name = SwiftUILayoutsDemo; 119 | packageProductDependencies = ( 120 | D74C964B2855E58C00CEE4B9 /* SwiftUILayouts */, 121 | ); 122 | productName = SwiftUILayouts; 123 | productReference = D7A30932285486B800413565 /* SwiftUILayoutsDemo.app */; 124 | productType = "com.apple.product-type.application"; 125 | }; 126 | /* End PBXNativeTarget section */ 127 | 128 | /* Begin PBXProject section */ 129 | D7A3092A285486B800413565 /* Project object */ = { 130 | isa = PBXProject; 131 | attributes = { 132 | BuildIndependentTargetsInParallel = 1; 133 | LastSwiftUpdateCheck = 1400; 134 | LastUpgradeCheck = 1400; 135 | TargetAttributes = { 136 | D7A30931285486B800413565 = { 137 | CreatedOnToolsVersion = 14.0; 138 | }; 139 | }; 140 | }; 141 | buildConfigurationList = D7A3092D285486B800413565 /* Build configuration list for PBXProject "SwiftUILayoutsDemo" */; 142 | compatibilityVersion = "Xcode 14.0"; 143 | developmentRegion = en; 144 | hasScannedForEncodings = 0; 145 | knownRegions = ( 146 | en, 147 | Base, 148 | ); 149 | mainGroup = D7A30929285486B800413565; 150 | productRefGroup = D7A30933285486B800413565 /* Products */; 151 | projectDirPath = ""; 152 | projectRoot = ""; 153 | targets = ( 154 | D7A30931285486B800413565 /* SwiftUILayoutsDemo */, 155 | ); 156 | }; 157 | /* End PBXProject section */ 158 | 159 | /* Begin PBXResourcesBuildPhase section */ 160 | D7A30930285486B800413565 /* Resources */ = { 161 | isa = PBXResourcesBuildPhase; 162 | buildActionMask = 2147483647; 163 | files = ( 164 | D74C964E2855E6B600CEE4B9 /* README.md in Resources */, 165 | D7A3093D285486BA00413565 /* Preview Assets.xcassets in Resources */, 166 | D7A3093A285486BA00413565 /* Assets.xcassets in Resources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXResourcesBuildPhase section */ 171 | 172 | /* Begin PBXSourcesBuildPhase section */ 173 | D7A3092E285486B800413565 /* Sources */ = { 174 | isa = PBXSourcesBuildPhase; 175 | buildActionMask = 2147483647; 176 | files = ( 177 | D7A30944285486C900413565 /* LayoutTypes.swift in Sources */, 178 | D7A3094C285594A500413565 /* WaterfallTestView.swift in Sources */, 179 | D7A30938285486B800413565 /* ContentView.swift in Sources */, 180 | D7A3094A28558F1E00413565 /* FlowTestView.swift in Sources */, 181 | D7A30936285486B800413565 /* SwiftUILayoutsApp.swift in Sources */, 182 | ); 183 | runOnlyForDeploymentPostprocessing = 0; 184 | }; 185 | /* End PBXSourcesBuildPhase section */ 186 | 187 | /* Begin PBXTargetDependency section */ 188 | D74C96492855E49800CEE4B9 /* PBXTargetDependency */ = { 189 | isa = PBXTargetDependency; 190 | productRef = D74C96482855E49800CEE4B9 /* SwiftUILayouts */; 191 | }; 192 | /* End PBXTargetDependency section */ 193 | 194 | /* Begin XCBuildConfiguration section */ 195 | D7A3093E285486BA00413565 /* Debug */ = { 196 | isa = XCBuildConfiguration; 197 | buildSettings = { 198 | ALWAYS_SEARCH_USER_PATHS = NO; 199 | CLANG_ANALYZER_NONNULL = YES; 200 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 201 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 202 | CLANG_ENABLE_MODULES = YES; 203 | CLANG_ENABLE_OBJC_ARC = YES; 204 | CLANG_ENABLE_OBJC_WEAK = YES; 205 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 206 | CLANG_WARN_BOOL_CONVERSION = YES; 207 | CLANG_WARN_COMMA = YES; 208 | CLANG_WARN_CONSTANT_CONVERSION = YES; 209 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 210 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 211 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 212 | CLANG_WARN_EMPTY_BODY = YES; 213 | CLANG_WARN_ENUM_CONVERSION = YES; 214 | CLANG_WARN_INFINITE_RECURSION = YES; 215 | CLANG_WARN_INT_CONVERSION = YES; 216 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 217 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 218 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 219 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 220 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 221 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 222 | CLANG_WARN_STRICT_PROTOTYPES = YES; 223 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 224 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 225 | CLANG_WARN_UNREACHABLE_CODE = YES; 226 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 227 | COPY_PHASE_STRIP = NO; 228 | DEBUG_INFORMATION_FORMAT = dwarf; 229 | ENABLE_STRICT_OBJC_MSGSEND = YES; 230 | ENABLE_TESTABILITY = YES; 231 | GCC_C_LANGUAGE_STANDARD = gnu11; 232 | GCC_DYNAMIC_NO_PIC = NO; 233 | GCC_NO_COMMON_BLOCKS = YES; 234 | GCC_OPTIMIZATION_LEVEL = 0; 235 | GCC_PREPROCESSOR_DEFINITIONS = ( 236 | "DEBUG=1", 237 | "$(inherited)", 238 | ); 239 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 240 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 241 | GCC_WARN_UNDECLARED_SELECTOR = YES; 242 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 243 | GCC_WARN_UNUSED_FUNCTION = YES; 244 | GCC_WARN_UNUSED_VARIABLE = YES; 245 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 246 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 247 | MTL_FAST_MATH = YES; 248 | ONLY_ACTIVE_ARCH = YES; 249 | SDKROOT = iphoneos; 250 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 251 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 252 | }; 253 | name = Debug; 254 | }; 255 | D7A3093F285486BA00413565 /* Release */ = { 256 | isa = XCBuildConfiguration; 257 | buildSettings = { 258 | ALWAYS_SEARCH_USER_PATHS = NO; 259 | CLANG_ANALYZER_NONNULL = YES; 260 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 261 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 262 | CLANG_ENABLE_MODULES = YES; 263 | CLANG_ENABLE_OBJC_ARC = YES; 264 | CLANG_ENABLE_OBJC_WEAK = YES; 265 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 266 | CLANG_WARN_BOOL_CONVERSION = YES; 267 | CLANG_WARN_COMMA = YES; 268 | CLANG_WARN_CONSTANT_CONVERSION = YES; 269 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 270 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 271 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 272 | CLANG_WARN_EMPTY_BODY = YES; 273 | CLANG_WARN_ENUM_CONVERSION = YES; 274 | CLANG_WARN_INFINITE_RECURSION = YES; 275 | CLANG_WARN_INT_CONVERSION = YES; 276 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 277 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 278 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 279 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 280 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 281 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 282 | CLANG_WARN_STRICT_PROTOTYPES = YES; 283 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 284 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 285 | CLANG_WARN_UNREACHABLE_CODE = YES; 286 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 287 | COPY_PHASE_STRIP = NO; 288 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 289 | ENABLE_NS_ASSERTIONS = NO; 290 | ENABLE_STRICT_OBJC_MSGSEND = YES; 291 | GCC_C_LANGUAGE_STANDARD = gnu11; 292 | GCC_NO_COMMON_BLOCKS = YES; 293 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 294 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 295 | GCC_WARN_UNDECLARED_SELECTOR = YES; 296 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 297 | GCC_WARN_UNUSED_FUNCTION = YES; 298 | GCC_WARN_UNUSED_VARIABLE = YES; 299 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 300 | MTL_ENABLE_DEBUG_INFO = NO; 301 | MTL_FAST_MATH = YES; 302 | SDKROOT = iphoneos; 303 | SWIFT_COMPILATION_MODE = wholemodule; 304 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 305 | VALIDATE_PRODUCT = YES; 306 | }; 307 | name = Release; 308 | }; 309 | D7A30941285486BA00413565 /* Debug */ = { 310 | isa = XCBuildConfiguration; 311 | buildSettings = { 312 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 313 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 314 | CODE_SIGN_STYLE = Automatic; 315 | CURRENT_PROJECT_VERSION = 1; 316 | DEVELOPMENT_ASSET_PATHS = "\"SwiftUILayoutsDemo/Preview Content\""; 317 | DEVELOPMENT_TEAM = 2JV298SK2V; 318 | ENABLE_PREVIEWS = YES; 319 | GENERATE_INFOPLIST_FILE = YES; 320 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 321 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 322 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 323 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 324 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 325 | LD_RUNPATH_SEARCH_PATHS = ( 326 | "$(inherited)", 327 | "@executable_path/Frameworks", 328 | ); 329 | MARKETING_VERSION = 1.0; 330 | PRODUCT_BUNDLE_IDENTIFIER = com.apptekstudios.SwiftUILayouts; 331 | PRODUCT_NAME = "$(TARGET_NAME)"; 332 | SWIFT_EMIT_LOC_STRINGS = YES; 333 | SWIFT_VERSION = 5.0; 334 | TARGETED_DEVICE_FAMILY = "1,2"; 335 | }; 336 | name = Debug; 337 | }; 338 | D7A30942285486BA00413565 /* Release */ = { 339 | isa = XCBuildConfiguration; 340 | buildSettings = { 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 342 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 343 | CODE_SIGN_STYLE = Automatic; 344 | CURRENT_PROJECT_VERSION = 1; 345 | DEVELOPMENT_ASSET_PATHS = "\"SwiftUILayoutsDemo/Preview Content\""; 346 | DEVELOPMENT_TEAM = 2JV298SK2V; 347 | ENABLE_PREVIEWS = YES; 348 | GENERATE_INFOPLIST_FILE = YES; 349 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 350 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 351 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 352 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 353 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 354 | LD_RUNPATH_SEARCH_PATHS = ( 355 | "$(inherited)", 356 | "@executable_path/Frameworks", 357 | ); 358 | MARKETING_VERSION = 1.0; 359 | PRODUCT_BUNDLE_IDENTIFIER = com.apptekstudios.SwiftUILayouts; 360 | PRODUCT_NAME = "$(TARGET_NAME)"; 361 | SWIFT_EMIT_LOC_STRINGS = YES; 362 | SWIFT_VERSION = 5.0; 363 | TARGETED_DEVICE_FAMILY = "1,2"; 364 | }; 365 | name = Release; 366 | }; 367 | /* End XCBuildConfiguration section */ 368 | 369 | /* Begin XCConfigurationList section */ 370 | D7A3092D285486B800413565 /* Build configuration list for PBXProject "SwiftUILayoutsDemo" */ = { 371 | isa = XCConfigurationList; 372 | buildConfigurations = ( 373 | D7A3093E285486BA00413565 /* Debug */, 374 | D7A3093F285486BA00413565 /* Release */, 375 | ); 376 | defaultConfigurationIsVisible = 0; 377 | defaultConfigurationName = Release; 378 | }; 379 | D7A30940285486BA00413565 /* Build configuration list for PBXNativeTarget "SwiftUILayoutsDemo" */ = { 380 | isa = XCConfigurationList; 381 | buildConfigurations = ( 382 | D7A30941285486BA00413565 /* Debug */, 383 | D7A30942285486BA00413565 /* Release */, 384 | ); 385 | defaultConfigurationIsVisible = 0; 386 | defaultConfigurationName = Release; 387 | }; 388 | /* End XCConfigurationList section */ 389 | 390 | /* Begin XCSwiftPackageProductDependency section */ 391 | D74C96482855E49800CEE4B9 /* SwiftUILayouts */ = { 392 | isa = XCSwiftPackageProductDependency; 393 | productName = SwiftUILayouts; 394 | }; 395 | D74C964B2855E58C00CEE4B9 /* SwiftUILayouts */ = { 396 | isa = XCSwiftPackageProductDependency; 397 | productName = SwiftUILayouts; 398 | }; 399 | /* End XCSwiftPackageProductDependency section */ 400 | }; 401 | rootObject = D7A3092A285486B800413565 /* Project object */; 402 | } 403 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo.xcodeproj/xcuserdata/tbrennan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo.xcodeproj/xcuserdata/tbrennan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | SwiftUILayouts.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | SwiftUILayoutsDemo.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/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 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo0.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo0.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo0.imageset/demo0.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo0.imageset/demo0.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo1.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo1.imageset/demo1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo1.imageset/demo1.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo2.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo2.imageset/demo2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo2.imageset/demo2.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo3.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo3.imageset/demo3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo3.imageset/demo3.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo4.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo4.imageset/demo4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo4.imageset/demo4.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo5.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo5.imageset/demo5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo5.imageset/demo5.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo6.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo6.imageset/demo6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo6.imageset/demo6.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo7.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Assets.xcassets/demo7.imageset/demo7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apptekstudios/SwiftUILayouts/de1da15d1afee3b41dc628b22a4cfef381d3986f/Demo/SwiftUILayoutsDemo/Assets.xcassets/demo7.imageset/demo7.jpeg -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | 12 | @State var currentLayout: LayoutType? 13 | @State var columnVisibility: NavigationSplitViewVisibility = .doubleColumn 14 | 15 | var body: some View { 16 | NavigationSplitView(columnVisibility: $columnVisibility) { 17 | List(LayoutType.allCases, selection: $currentLayout) { layout in 18 | Text(layout.title) 19 | } 20 | .navigationTitle("Layout Demos") 21 | } detail: { 22 | NavigationStack { 23 | DetailView(layoutType: currentLayout) 24 | } 25 | } 26 | .navigationSplitViewStyle(.balanced) 27 | } 28 | } 29 | 30 | enum LayoutType: String, Identifiable, CaseIterable { 31 | case flow 32 | case waterfall 33 | 34 | var id: Self { self } 35 | var title: String { 36 | switch self { 37 | case .flow: return "Flow Layout" 38 | case .waterfall: return "Waterfall Layout" 39 | } 40 | } 41 | } 42 | 43 | struct DetailView: View { 44 | var layoutType: LayoutType? 45 | var body: some View { 46 | switch layoutType { 47 | case .none: Text("Select a layout from the sidebar.") 48 | case .flow: FlowTestView() 49 | case .waterfall: WaterfallTestView() 50 | 51 | } 52 | } 53 | } 54 | 55 | struct ContentView_Previews: PreviewProvider { 56 | static var previews: some View { 57 | ContentView() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/FlowTestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUILayouts 10 | 11 | struct FlowTestView: View { 12 | @State var testContent = ["Hello World", "Custom layouts in SwiftUI wow!", "This is a very long string that takes up multiple lines in portrait mode", "String with\nline break", "Short text"] 13 | @State var horizontalAlignment: HorizontalAlignment = .leading 14 | @State var verticalAlignment: VerticalAlignment = .top 15 | var body: some View { 16 | ScrollView(.vertical) { 17 | VStack(spacing: 0) { 18 | AnyLayout(FlowLayout(alignment:.init(horizontal: horizontalAlignment, vertical: verticalAlignment))) { 19 | ForEach(testContent, id: \.self) { i in 20 | Text(i) 21 | .padding(6) 22 | .frame(maxHeight: .infinity) 23 | .background( 24 | RoundedRectangle(cornerRadius: 5, style: .continuous) 25 | .fill(Color(.secondarySystemBackground)) 26 | ) 27 | } 28 | }.padding(10) 29 | Divider() 30 | } 31 | .animation(.default, value: horizontalAlignment) 32 | .animation(.default, value: verticalAlignment) 33 | .animation(.default, value: testContent) 34 | } 35 | .safeAreaInset(edge: .top) { 36 | HStack { 37 | picker.pickerStyle(.menu) 38 | Spacer() 39 | Button("Shuffle") { 40 | testContent.shuffle() 41 | } 42 | }.padding().background(.thinMaterial) 43 | } 44 | .navigationTitle("Flow Layout") 45 | } 46 | var picker: some View { 47 | Picker("Alignment", selection: $horizontalAlignment) { 48 | Text("Leading").tag(HorizontalAlignment.leading) 49 | Text("Center").tag(HorizontalAlignment.center) 50 | Text("Trailing").tag(HorizontalAlignment.trailing) 51 | }.pickerStyle(.segmented) 52 | } 53 | } 54 | 55 | extension HorizontalAlignment: Hashable { 56 | public func hash(into hasher: inout Hasher) { 57 | hasher.combine(String(describing: self)) 58 | } 59 | } 60 | 61 | 62 | struct FlowTestView_Previews: PreviewProvider { 63 | static var previews: some View { 64 | FlowTestView() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/LayoutTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutTypes.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | enum LayoutTypes: String, CaseIterable { 12 | case HStack 13 | case VStack 14 | case Grid 15 | case Flow 16 | } 17 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/SwiftUILayoutsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUILayoutsApp.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct SwiftUILayoutsApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Demo/SwiftUILayoutsDemo/WaterfallTestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUILayouts 10 | 11 | struct DemoItem: Identifiable, Equatable { 12 | var imageName: String 13 | var id = UUID() 14 | } 15 | 16 | struct WaterfallTestView: View { 17 | @State var testContent: [DemoItem] = (0...7).map { DemoItem(imageName: "demo\($0)") } // (0...10).flatMap { _ in (existing) } 18 | @State var columns: Int = 3 19 | var body: some View { 20 | ScrollView(.vertical) { 21 | VStack(spacing: 0) { 22 | AnyLayout(VerticalWaterfallLayout(columns: columns)) { 23 | ForEach(testContent) { item in 24 | Image(item.imageName) 25 | .resizable() 26 | .aspectRatio(contentMode: .fill) 27 | } 28 | }.padding(.horizontal, 10) 29 | Divider() 30 | } 31 | .animation(.default, value: columns) 32 | .animation(.default, value: testContent) 33 | 34 | } 35 | .safeAreaInset(edge: .top) { 36 | HStack { 37 | Stepper("Columns", value: $columns, in: 1...5) 38 | Spacer() 39 | Button("Shuffle") { 40 | testContent.shuffle() 41 | } 42 | }.padding().background(.thinMaterial) 43 | } 44 | .navigationTitle("Waterfall") 45 | } 46 | } 47 | 48 | struct WaterfallTestView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | NavigationStack { 51 | WaterfallTestView() 52 | } 53 | 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftUILayouts", 8 | platforms: [.iOS(.v16), .macOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "SwiftUILayouts", 13 | targets: ["SwiftUILayouts"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "SwiftUILayouts", 24 | dependencies: []), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUILayouts 2 | 3 | A library of commonly requested layouts. Implemented using SwiftUI's native layout system. 4 | 5 | **NOTE: SwiftUILayouts requires iOS 16 or above, as this uses the new SwiftUI Layout system.** 6 | 7 | ### Why use these? 8 | - Native SwiftUI Layouts are fast and can be safely embedded anywhere in SwiftUI views 9 | - Each layout is self-contained within a file. Want to customise it? Just copy the code (and send us a pull request with improvements!) 10 | 11 | ### Check out the demo app to see them in action 12 | 13 | # Layouts 14 | 15 | ## Flow Layout 16 | Ideal for tag lists, amongst many other uses. Lay out views horizontally, wrapping to the next line when space runs out 17 | 18 | ## Waterfall Layout 19 | Great for presenting images of varying aspect ratios. Ensures columns are filled equally while preserving order. 20 | 21 | 22 | # Credit 23 | The flow layout code was heavily inspired by objc.io's great exploration of the new Layout system. 24 | -------------------------------------------------------------------------------- /Sources/SwiftUILayouts/CompressingHStack.swift: -------------------------------------------------------------------------------- 1 | /* 2 | ADAPTED FROM APPLE EXAMPLE 3 | 4 | Abstract: 5 | A custom horizontal stack that offers all its subviews the width of its largest subview. 6 | */ 7 | 8 | import SwiftUI 9 | 10 | public struct CompressingHStack: Layout { 11 | public init(spacing: Double? = nil) { 12 | self.spacing = spacing 13 | } 14 | 15 | var spacing: Double? 16 | 17 | /// Returns a size that the layout container needs to arrange its subviews 18 | /// horizontally. 19 | /// - Tag: sizeThatFitsHorizontal 20 | public func sizeThatFits( 21 | proposal: ProposedViewSize, 22 | subviews: Subviews, 23 | cache: inout Void 24 | ) -> CGSize { 25 | guard !subviews.isEmpty else { return .zero } 26 | 27 | let maxSize = maxSize(subviews: subviews) 28 | let sizes = subviews.map { subview in 29 | calcSize(for: subview, targetSize: maxSize) 30 | } 31 | let spacing = spacing(subviews: subviews) 32 | let totalSpacing = spacing.reduce(0) { $0 + $1 } 33 | 34 | return CGSize( 35 | width: sizes.map(\.width).reduce(into: .zero, { $0 += $1}) + totalSpacing, 36 | height: sizes.map(\.height).max() ?? .zero) 37 | } 38 | 39 | /// Places the subviews in a horizontal stack. 40 | /// - Tag: placeSubviewsHorizontal 41 | public func placeSubviews( 42 | in bounds: CGRect, 43 | proposal: ProposedViewSize, 44 | subviews: Subviews, 45 | cache: inout Void 46 | ) { 47 | guard !subviews.isEmpty else { return } 48 | 49 | let maxSize = maxSize(subviews: subviews) 50 | let spacing = spacing(subviews: subviews) 51 | 52 | var nextX = bounds.minX 53 | 54 | for index in subviews.indices { 55 | let size = calcSize(for: subviews[index], targetSize: maxSize) 56 | let proposal = ProposedViewSize(size) 57 | subviews[index].place( 58 | at: CGPoint(x: nextX + size.width/2, y: bounds.midY), 59 | anchor: .center, 60 | proposal: proposal) 61 | nextX += size.width + spacing[index] 62 | } 63 | } 64 | 65 | private func calcSize(for subview: LayoutSubview, targetSize: CGSize) -> CGSize { 66 | return subview.sizeThatFits(ProposedViewSize(width: nil, height: targetSize.height)) 67 | } 68 | 69 | /// Finds the largest ideal size of the subviews. 70 | private func maxSize(subviews: Subviews) -> CGSize { 71 | let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) } 72 | let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in 73 | CGSize( 74 | width: max(currentMax.width, subviewSize.width), 75 | height: max(currentMax.height, subviewSize.height)) 76 | } 77 | 78 | return maxSize 79 | } 80 | 81 | /// Gets an array of preferred spacing sizes between subviews in the 82 | /// horizontal dimension. 83 | private func spacing(subviews: Subviews) -> [CGFloat] { 84 | subviews.indices.map { index in 85 | guard index < subviews.count - 1 else { return 0 } 86 | return spacing ?? subviews[index].spacing.distance( 87 | to: subviews[index + 1].spacing, 88 | along: .horizontal) 89 | } 90 | } 91 | 92 | public static var layoutProperties: LayoutProperties { 93 | var properties = LayoutProperties() 94 | properties.stackOrientation = .horizontal 95 | return properties 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/SwiftUILayouts/EqualHStack.swift: -------------------------------------------------------------------------------- 1 | /* 2 | ADAPTED FROM APPLE EXAMPLE 3 | 4 | Abstract: 5 | A custom horizontal stack that offers all its subviews the width of its largest subview. 6 | */ 7 | 8 | import SwiftUI 9 | 10 | /// A custom horizontal stack that offers all its subviews the width of its 11 | /// widest subview. 12 | /// 13 | /// This custom layout arranges views horizontally, giving each the width needed 14 | /// by the widest subview. 15 | /// 16 | /// ![Three rectangles arranged in a horizontal line. Each rectangle contains 17 | /// one smaller rectangle. The smaller rectangles have varying widths. Dashed 18 | /// lines above each of the container rectangles show that the larger rectangles 19 | /// all have the same width as each other.](voting-buttons) 20 | /// 21 | /// The custom stack implements the protocol's two required methods. First, 22 | /// ``sizeThatFits(proposal:subviews:cache:)`` reports the container's size, 23 | /// given a set of subviews. 24 | /// 25 | /// ```swift 26 | /// let maxSize = maxSize(subviews: subviews) 27 | /// let spacing = spacing(subviews: subviews) 28 | /// let totalSpacing = spacing.reduce(0) { $0 + $1 } 29 | /// 30 | /// return CGSize( 31 | /// width: maxSize.width * CGFloat(subviews.count) + totalSpacing, 32 | /// height: maxSize.height) 33 | /// ``` 34 | /// 35 | /// This method combines the largest size in each dimension with the horizontal 36 | /// spacing between subviews to find the container's total size. Then, 37 | /// ``placeSubviews(in:proposal:subviews:cache:)`` tells each of the subviews 38 | /// where to appear within the layout's bounds. 39 | /// 40 | /// ```swift 41 | /// let maxSize = maxSize(subviews: subviews) 42 | /// let spacing = spacing(subviews: subviews) 43 | /// 44 | /// let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height) 45 | /// var nextX = bounds.minX + maxSize.width / 2 46 | /// 47 | /// for index in subviews.indices { 48 | /// subviews[index].place( 49 | /// at: CGPoint(x: nextX, y: bounds.midY), 50 | /// anchor: .center, 51 | /// proposal: placementProposal) 52 | /// nextX += maxSize.width + spacing[index] 53 | /// } 54 | /// ``` 55 | /// 56 | /// The method creates a single size proposal for the subviews, and then uses 57 | /// that, along with a point that changes for each subview, to arrange the 58 | /// subviews in a horizontal line with default spacing. 59 | public struct EqualHStack: Layout { 60 | public init(spacing: Double? = nil, fillAvailable: Bool = false) { 61 | self.spacing = spacing 62 | self.fillAvailable = fillAvailable 63 | } 64 | 65 | var spacing: Double? 66 | var fillAvailable: Bool 67 | 68 | /// Returns a size that the layout container needs to arrange its subviews 69 | /// horizontally. 70 | /// - Tag: sizeThatFitsHorizontal 71 | public func sizeThatFits( 72 | proposal: ProposedViewSize, 73 | subviews: Subviews, 74 | cache: inout Void 75 | ) -> CGSize { 76 | guard !subviews.isEmpty else { return .zero } 77 | 78 | let spacing = spacing(subviews: subviews) 79 | let totalSpacing = spacing.reduce(0) { $0 + $1 } 80 | let maxSize = maxSize(subviews: subviews) 81 | 82 | if fillAvailable, let width = proposal.width { 83 | return CGSize( 84 | width: width, 85 | height: maxSize.height) 86 | } else { 87 | let sumWidth = subviews.reduce(into: Double.zero) { partialResult, subview in 88 | partialResult += calcSize(for: subview, targetSize: maxSize).width 89 | } 90 | 91 | return CGSize( 92 | width: sumWidth + totalSpacing, 93 | height: maxSize.height) 94 | } 95 | } 96 | 97 | /// Places the subviews in a horizontal stack. 98 | /// - Tag: placeSubviewsHorizontal 99 | public func placeSubviews( 100 | in bounds: CGRect, 101 | proposal: ProposedViewSize, 102 | subviews: Subviews, 103 | cache: inout Void 104 | ) { 105 | guard !subviews.isEmpty else { return } 106 | 107 | let maxSize = maxSize(subviews: subviews) 108 | let spacing = spacing(subviews: subviews) 109 | var nextX = bounds.minX 110 | 111 | if fillAvailable, let width = proposal.width { 112 | let interim = subviews.indices.compactMap { index in 113 | InterimResult(subview: subviews[index], isNonExpandableWithWidth: isNonExpandableView(subviews[index])) 114 | } 115 | let nonExpandableSpace = interim.reduce(into: Double.zero) { partialResult, item in 116 | if let itemWidth = item.isNonExpandableWithWidth { 117 | partialResult += itemWidth 118 | } 119 | } 120 | let expandableCount = interim.reduce(into: Int.zero) { partialResult, item in 121 | if item.isNonExpandableWithWidth == nil { 122 | partialResult += 1 123 | } 124 | } 125 | let targetWidth = (expandableCount == .zero) ? 0 : (width - nonExpandableSpace) / Double(expandableCount) 126 | for index in subviews.indices { 127 | let size = calcSize(for: subviews[index], targetSize: CGSize(width: targetWidth, height: maxSize.height)) 128 | let proposal = ProposedViewSize(size) 129 | subviews[index].place( 130 | at: CGPoint(x: nextX + size.width/2, y: bounds.midY), 131 | anchor: .center, 132 | proposal: proposal) 133 | nextX += size.width + spacing[index] 134 | } 135 | } else { 136 | for index in subviews.indices { 137 | let size = calcSize(for: subviews[index], targetSize: maxSize) 138 | let proposal = ProposedViewSize(size) 139 | subviews[index].place( 140 | at: CGPoint(x: nextX + size.width/2, y: bounds.midY), 141 | anchor: .center, 142 | proposal: proposal) 143 | nextX += size.width + spacing[index] 144 | } 145 | } 146 | } 147 | 148 | private func calcSize(for subview: LayoutSubview, targetSize: CGSize) -> CGSize { 149 | let actualSize = isNonExpandableView(subview) ?? targetSize.width 150 | return CGSize(width: actualSize, height: targetSize.height) 151 | } 152 | 153 | private func isNonExpandableView(_ subview: LayoutSubview) -> Double? { 154 | let itemMaxWidth = subview.sizeThatFits(.infinity).width 155 | return itemMaxWidth <= 1 ? itemMaxWidth : nil 156 | } 157 | 158 | /// Finds the largest ideal size of the subviews. 159 | private func maxSize(subviews: Subviews) -> CGSize { 160 | let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) } 161 | let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in 162 | CGSize( 163 | width: max(currentMax.width, subviewSize.width), 164 | height: max(currentMax.height, subviewSize.height)) 165 | } 166 | 167 | return maxSize 168 | } 169 | 170 | /// Gets an array of preferred spacing sizes between subviews in the 171 | /// horizontal dimension. 172 | private func spacing(subviews: Subviews) -> [CGFloat] { 173 | subviews.indices.map { index in 174 | guard index < subviews.count - 1 else { return 0 } 175 | return spacing ?? subviews[index].spacing.distance( 176 | to: subviews[index + 1].spacing, 177 | along: .horizontal) 178 | } 179 | } 180 | 181 | public static var layoutProperties: LayoutProperties { 182 | var properties = LayoutProperties() 183 | properties.stackOrientation = .horizontal 184 | return properties 185 | } 186 | 187 | struct InterimResult { 188 | var subview: LayoutSubview 189 | var isNonExpandableWithWidth: Double? 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/SwiftUILayouts/EqualVStack.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See LICENSE folder for this sample’s licensing information. 3 | 4 | Abstract: 5 | A custom vertical stack that offers all its subviews the width of its largest subview. 6 | */ 7 | 8 | import SwiftUI 9 | 10 | /// A custom vertical stack that offers all its subviews the width of its 11 | /// widest subview. 12 | /// 13 | /// This custom layout behaves almost identically to the ``MyEqualWidthHStack``, 14 | /// except that it arranges equal-width subviews in a vertical stack, rather 15 | /// than a horizontal one. It also implements a cache. 16 | /// 17 | /// ### Adding a cache 18 | /// 19 | /// The methods of the 20 | /// [`Layout`](https://developer.apple.com/documentation/swiftui/layout) 21 | /// protocol take a bidirectional `cache` 22 | /// parameter. The cache provides access to optional storage that's shared among 23 | /// all the methods of a particular layout instance. To demonstrate the use of a 24 | /// cache, this layout creates storage to share size and spacing calculations 25 | /// between its ``sizeThatFits(proposal:subviews:cache:)`` and 26 | /// ``placeSubviews(in:proposal:subviews:cache:)`` implementations. 27 | /// 28 | /// First, the layout defines a ``CacheData`` type for the storage: 29 | /// 30 | /// ```swift 31 | /// struct CacheData { 32 | /// let maxSize: CGSize 33 | /// let spacing: [CGFloat] 34 | /// let totalSpacing: CGFloat 35 | /// } 36 | /// ``` 37 | /// 38 | /// It then implements the protocol's optional ``makeCache(subviews:)`` 39 | /// method to do the calculations for a set of subviews, returning a value of 40 | /// the type defined above. 41 | /// 42 | /// ```swift 43 | /// func makeCache(subviews: Subviews) -> CacheData { 44 | /// let maxSize = maxSize(subviews: subviews) 45 | /// let spacing = spacing(subviews: subviews) 46 | /// let totalSpacing = spacing.reduce(0) { $0 + $1 } 47 | /// 48 | /// return CacheData( 49 | /// maxSize: maxSize, 50 | /// spacing: spacing, 51 | /// totalSpacing: totalSpacing) 52 | /// } 53 | /// ``` 54 | /// 55 | /// If the subviews change, SwiftUI calls the layout's 56 | /// ``updateCache(_:subviews:)`` method. The default implementation of that 57 | /// method calls ``makeCache(subviews:)`` again, which recalculates the data. 58 | /// Then the ``sizeThatFits(proposal:subviews:cache:)`` and 59 | /// ``placeSubviews(in:proposal:subviews:cache:)`` methods make 60 | /// use of their `cache` parameter to retrieve the data. For example, 61 | /// ``placeSubviews(in:proposal:subviews:cache:)`` reads the size and the 62 | /// spacing array from the cache. 63 | /// 64 | /// ```swift 65 | /// let maxSize = cache.maxSize 66 | /// let spacing = cache.spacing 67 | /// ``` 68 | /// 69 | /// Contrast this with ``MyEqualWidthHStack``, which doesn't use a 70 | /// cache, and instead calculates the size and spacing information every time 71 | /// it needs that information. 72 | /// 73 | /// > Note: Most simple layouts, including this one, don't 74 | /// gain much efficiency from using a cache. You can profile your app 75 | /// with Instruments to find out whether a particular layout type actually 76 | /// benefits from a cache. 77 | public struct EqualVStack: Layout { 78 | public init(spacing: Double? = nil) { 79 | self.spacing = spacing 80 | } 81 | 82 | var spacing: Double? 83 | 84 | /// Returns a size that the layout container needs to arrange its subviews 85 | /// vertically with equal widths. 86 | public func sizeThatFits( 87 | proposal: ProposedViewSize, 88 | subviews: Subviews, 89 | cache: inout CacheData 90 | ) -> CGSize { 91 | guard !subviews.isEmpty else { return .zero } 92 | 93 | // Load size and spacing information from the cache. 94 | let maxSize = cache.maxSize 95 | let totalSpacing = cache.totalSpacing 96 | 97 | return CGSize( 98 | width: maxSize.width, 99 | height: maxSize.height * CGFloat(subviews.count) + totalSpacing) 100 | } 101 | 102 | /// Places the subviews in a vertical stack. 103 | /// - Tag: placeSubviewsVertical 104 | public func placeSubviews( 105 | in bounds: CGRect, 106 | proposal: ProposedViewSize, 107 | subviews: Subviews, 108 | cache: inout CacheData 109 | ) { 110 | guard !subviews.isEmpty else { return } 111 | 112 | // Load size and spacing information from the cache. 113 | let maxSize = cache.maxSize 114 | let spacing = cache.spacing 115 | 116 | let placementProposal = ProposedViewSize(width: maxSize.width, height: bounds.height) 117 | var nextY = bounds.minY + maxSize.height / 2 118 | 119 | for index in subviews.indices { 120 | subviews[index].place( 121 | at: CGPoint(x: bounds.midX, y: nextY), 122 | anchor: .center, 123 | proposal: placementProposal) 124 | nextY += maxSize.height + spacing[index] 125 | } 126 | } 127 | 128 | /// A type that stores cached data. 129 | /// - Tag: CacheData 130 | public struct CacheData { 131 | let maxSize: CGSize 132 | let spacing: [CGFloat] 133 | let totalSpacing: CGFloat 134 | } 135 | 136 | /// Creates a cache for a given set of subviews. 137 | /// 138 | /// When the subviews change, SwiftUI calls the ``updateCache(_:subviews:)`` 139 | /// method. The ``EqualVStack`` layout relies on the default 140 | /// implementation of that method, which just calls this method again 141 | /// to recreate the cache. 142 | /// - Tag: makeCache 143 | public func makeCache(subviews: Subviews) -> CacheData { 144 | let maxSize = maxSize(subviews: subviews) 145 | let spacing = spacing(subviews: subviews) 146 | let totalSpacing = spacing.reduce(0) { $0 + $1 } 147 | 148 | return CacheData( 149 | maxSize: maxSize, 150 | spacing: spacing, 151 | totalSpacing: totalSpacing) 152 | } 153 | 154 | /// Finds the largest ideal size of the subviews. 155 | private func maxSize(subviews: Subviews) -> CGSize { 156 | let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) } 157 | let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in 158 | CGSize( 159 | width: max(currentMax.width, subviewSize.width), 160 | height: max(currentMax.height, subviewSize.height)) 161 | } 162 | 163 | return maxSize 164 | } 165 | 166 | /// Gets an array of preferred spacing sizes between subviews in the 167 | /// vertical dimension. 168 | private func spacing(subviews: Subviews) -> [CGFloat] { 169 | subviews.indices.map { index in 170 | guard index < subviews.count - 1 else { return 0 } 171 | 172 | return spacing ?? subviews[index].spacing.distance( 173 | to: subviews[index + 1].spacing, 174 | along: .vertical) 175 | } 176 | } 177 | public static var layoutProperties: LayoutProperties { 178 | var properties = LayoutProperties() 179 | properties.stackOrientation = .vertical 180 | return properties 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Sources/SwiftUILayouts/FlowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowLayout.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 11/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct FlowLayout: Layout { 11 | var alignment: Alignment 12 | var spacingX: Double 13 | var spacingY: Double 14 | // All items in line offered the height of the largest item 15 | var fillLineHeight: Bool 16 | 17 | public init(alignment: Alignment = .leading, spacingX: Double = 10, spacingY: Double = 10, fillLineHeight: Bool = false) { 18 | self.alignment = alignment 19 | self.spacingX = spacingX 20 | self.spacingY = spacingY 21 | self.fillLineHeight = fillLineHeight 22 | } 23 | 24 | public struct LayoutCache { 25 | // If this changes invalidate the cache 26 | var targetContainerWidth: Double 27 | var items: [Int: CacheItem] = [:] 28 | var size: CGSize = .zero 29 | 30 | func ifValidForSize(_ width: Double) -> Self? { 31 | guard targetContainerWidth == width else { return nil } 32 | return self 33 | } 34 | } 35 | struct Line { 36 | var y: Double 37 | var height: Double = 0 38 | var width: Double = 0 39 | var maxY: Double { y + height } 40 | var items: [Int: CacheItem] = [:] 41 | 42 | mutating func applyAlignment(_ alignment: Alignment, layoutWidth: Double, fillLineHeight: Bool) { 43 | if fillLineHeight { 44 | for (index, _) in items { 45 | items[index]?.position.y = y 46 | items[index]?.size.height = height 47 | } 48 | } else { 49 | switch alignment.vertical { 50 | case .center: 51 | let centerY = y + (height / 2) 52 | for (index, item) in items { 53 | items[index]?.position.y = centerY - item.size.height / 2 54 | } 55 | case .bottom: 56 | let bottomY = y + height 57 | for (index, item) in items { 58 | items[index]?.position.y = bottomY - item.size.height 59 | } 60 | default: break 61 | } 62 | } 63 | switch alignment.horizontal { 64 | case .center: 65 | let xOffset = (layoutWidth - width) / 2 66 | for index in items.keys { 67 | items[index]?.position.x += xOffset 68 | } 69 | case .trailing: 70 | let xOffset = (layoutWidth - width) 71 | for index in items.keys { 72 | items[index]?.position.x += xOffset 73 | } 74 | default: break 75 | } 76 | } 77 | } 78 | struct CacheItem { 79 | var position: CGPoint 80 | var size: CGSize 81 | } 82 | 83 | public func makeCache(subviews: Subviews) -> LayoutCache? { 84 | return nil 85 | } 86 | 87 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache?) -> CGSize { 88 | let containerWidth = proposal.replacingUnspecifiedDimensions().width 89 | let calc = layout(subviews: subviews, containerWidth: containerWidth) 90 | cache = calc 91 | return calc.size 92 | } 93 | 94 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache?) { 95 | let calc = cache?.ifValidForSize(proposal.replacingUnspecifiedDimensions().width) ?? layout(subviews: subviews, containerWidth: bounds.width) 96 | for (index, subview) in zip(subviews.indices, subviews) { 97 | if let value = calc.items[index] { 98 | subview.place(at: bounds.origin + value.position, 99 | proposal: .init(value.size)) 100 | } 101 | } 102 | } 103 | 104 | func layout(subviews: Subviews, containerWidth: CGFloat) -> LayoutCache { 105 | var result: LayoutCache = .init(targetContainerWidth: containerWidth) 106 | var currentPosition: CGPoint = .zero 107 | var currentLineHeight: CGFloat = 0 108 | var maxX: CGFloat = 0 109 | var lines: [Line] = [Line(y: 0)] 110 | for (index, subview) in zip(subviews.indices, subviews) { 111 | let size = subview.sizeThatFits(.init(width: containerWidth, height: nil)) 112 | if currentPosition.x + spacingX + size.width > containerWidth { 113 | currentLineHeight = 0 114 | currentPosition.x = 0 115 | currentPosition.y += lines[lines.endIndex - 1].height + spacingY 116 | lines.append(Line(y: currentPosition.y)) 117 | } else if lines.last?.items.isEmpty != true { 118 | currentPosition.x += spacingX 119 | } 120 | lines[lines.endIndex - 1].items[index] = .init(position: currentPosition, size: size) 121 | currentPosition.x += size.width 122 | maxX = min(containerWidth, max(maxX, currentPosition.x)) 123 | currentLineHeight = max(currentLineHeight, size.height) 124 | lines[lines.endIndex - 1].width = currentPosition.x 125 | lines[lines.endIndex - 1].height = currentLineHeight 126 | } 127 | for index in lines.indices { 128 | lines[index].applyAlignment(alignment, layoutWidth: maxX, fillLineHeight: fillLineHeight) 129 | } 130 | result.size = CGSize(width: maxX, height: lines.last?.maxY ?? 0) 131 | result.items = lines.reduce(into: [Int: CacheItem](), { partialResult, line in 132 | partialResult.merge(line.items, uniquingKeysWith: {$1}) 133 | }) 134 | return result 135 | } 136 | public static var layoutProperties: LayoutProperties { 137 | var properties = LayoutProperties() 138 | properties.stackOrientation = .horizontal 139 | return properties 140 | } 141 | } 142 | 143 | func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint { 144 | return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) 145 | } 146 | -------------------------------------------------------------------------------- /Sources/SwiftUILayouts/VerticalWaterfallLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalWaterfallLayout.swift 3 | // SwiftUILayouts 4 | // 5 | // Created by T Brennan on 12/6/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct VerticalWaterfallLayout: Layout { 11 | var columns: Int 12 | var spacingX: Double 13 | var spacingY: Double 14 | 15 | public init(columns: Int = 3, spacingX: Double = 10, spacingY: Double = 10) { 16 | self.columns = columns 17 | self.spacingX = spacingX 18 | self.spacingY = spacingY 19 | } 20 | 21 | public struct LayoutCache { 22 | // If this changes invalidate the cache 23 | var targetContainerWidth: Double 24 | // If this changes invalidate the cache 25 | var columnCount: Int 26 | var items: [Int: CacheItem] = [:] 27 | var size: CGSize = .zero 28 | 29 | func ifValidForParams(_ width: Double, columns: Int) -> Self? { 30 | guard targetContainerWidth == width, 31 | columnCount == columns 32 | else { return nil } 33 | return self 34 | } 35 | } 36 | struct Column { 37 | var height: Double = 0 38 | var width: Double = 0 39 | var items: [Int: CacheItem] = [:] 40 | } 41 | struct CacheItem { 42 | var position: CGPoint 43 | var size: CGSize 44 | } 45 | 46 | public func makeCache(subviews: Subviews) -> LayoutCache? { 47 | return nil 48 | } 49 | 50 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache?) -> CGSize { 51 | let containerWidth = proposal.replacingUnspecifiedDimensions().width 52 | let calc = layout(subviews: subviews, containerWidth: containerWidth) 53 | cache = calc 54 | return calc.size 55 | } 56 | 57 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache?) { 58 | let calc = cache?.ifValidForParams(proposal.replacingUnspecifiedDimensions().width, columns: columns) ?? layout(subviews: subviews, containerWidth: bounds.width) 59 | for (index, subview) in zip(subviews.indices, subviews) { 60 | if let value = calc.items[index] { 61 | subview.place(at: bounds.origin + value.position, 62 | proposal: .init(value.size)) 63 | } 64 | } 65 | } 66 | 67 | func layout(subviews: Subviews, containerWidth: CGFloat) -> LayoutCache { 68 | guard containerWidth != 0 else {return LayoutCache(targetContainerWidth: 0, columnCount: columns)} 69 | var result: LayoutCache = .init(targetContainerWidth: containerWidth, columnCount: columns) 70 | let columnWidth = (containerWidth - Double(columns - 1) * spacingX) / Double(columns) 71 | var columns: [Column] = .init(repeating: Column(width: columnWidth), count: columns) 72 | for (index, subview) in zip(subviews.indices, subviews) { 73 | let size = subview.sizeThatFits(.init(width: columnWidth, height: nil)) 74 | let smallestColumnIndex = zip(columns, columns.indices).min(by: { $0.0.height < $1.0.height })?.1 ?? 0 75 | var currentColumn: Column { 76 | get { columns[smallestColumnIndex] } 77 | set { columns[smallestColumnIndex] = newValue } 78 | } 79 | let x = (columnWidth + spacingX) * Double(smallestColumnIndex) 80 | let y = currentColumn.height + spacingY 81 | let item = CacheItem(position: CGPoint(x: x, y: y), size: size) 82 | currentColumn.items[index] = item 83 | currentColumn.height = currentColumn.height + spacingY + item.size.height 84 | } 85 | let maxHeight = columns.max(by: { $0.height < $1.height })?.height ?? .zero 86 | result.size = CGSize(width: containerWidth, height: maxHeight) 87 | result.items = columns.reduce(into: [Int: CacheItem](), { partialResult, line in 88 | partialResult.merge(line.items, uniquingKeysWith: {$1}) 89 | }) 90 | return result 91 | } 92 | } 93 | --------------------------------------------------------------------------------