├── .gitignore ├── Demo ├── SplitDemo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── SplitDemo.xcscheme └── SplitDemo │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── DemoApp.swift │ ├── DemoModel.swift │ ├── DemoSplitter.swift │ ├── DemoToolbar.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── SplitDemo.entitlements │ └── SplitDemo.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SplitView │ ├── HSplit.swift │ ├── Split.swift │ ├── SplitConstraints.swift │ ├── SplitEnums.swift │ ├── SplitHolders.swift │ ├── SplitModifiers.swift │ ├── SplitStyling.swift │ ├── Splitter+Extensions.swift │ ├── Splitter.swift │ └── VSplit.swift └── Tests └── SplitViewTests └── SplitViewTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build/ 3 | .swiftpm/xcode/ 4 | xcuserdata/ 5 | Demo/SplitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ 6 | SplitDemo.xcworkspace/ 7 | -------------------------------------------------------------------------------- /Demo/SplitDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | AAD5CF7329956D1C00F94B1D /* SplitDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5CF7229956D1C00F94B1D /* SplitDemo.swift */; }; 11 | AAD5CF7529956D1C00F94B1D /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5CF7429956D1C00F94B1D /* DemoApp.swift */; }; 12 | AAD5CF7729956D1D00F94B1D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAD5CF7629956D1D00F94B1D /* Assets.xcassets */; }; 13 | AAD5CF7A29956D1D00F94B1D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAD5CF7929956D1D00F94B1D /* Preview Assets.xcassets */; }; 14 | AAD5CF8229956D8500F94B1D /* SplitView in Frameworks */ = {isa = PBXBuildFile; productRef = AAD5CF8129956D8500F94B1D /* SplitView */; }; 15 | AAF9E0C62996F5CE00EDD306 /* DemoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF9E0C52996F5CE00EDD306 /* DemoModel.swift */; }; 16 | AAF9E0C82996F80900EDD306 /* DemoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF9E0C72996F80900EDD306 /* DemoToolbar.swift */; }; 17 | AAF9E0CA2996F85A00EDD306 /* DemoSplitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF9E0C92996F85A00EDD306 /* DemoSplitter.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | AAC4D89C29956F14005FDD5C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 22 | AAD5CF6F29956D1C00F94B1D /* SplitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SplitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 | AAD5CF7229956D1C00F94B1D /* SplitDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitDemo.swift; sourceTree = ""; }; 24 | AAD5CF7429956D1C00F94B1D /* DemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; 25 | AAD5CF7629956D1D00F94B1D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | AAD5CF7929956D1D00F94B1D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 27 | AAD5CF8329956E3300F94B1D /* SplitDemo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SplitDemo.entitlements; sourceTree = ""; }; 28 | AAF9E0C52996F5CE00EDD306 /* DemoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoModel.swift; sourceTree = ""; }; 29 | AAF9E0C72996F80900EDD306 /* DemoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoToolbar.swift; sourceTree = ""; }; 30 | AAF9E0C92996F85A00EDD306 /* DemoSplitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoSplitter.swift; sourceTree = ""; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFrameworksBuildPhase section */ 34 | AAD5CF6C29956D1C00F94B1D /* Frameworks */ = { 35 | isa = PBXFrameworksBuildPhase; 36 | buildActionMask = 2147483647; 37 | files = ( 38 | AAD5CF8229956D8500F94B1D /* SplitView in Frameworks */, 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | AAD5CF6629956D1C00F94B1D = { 46 | isa = PBXGroup; 47 | children = ( 48 | AAD5CF7129956D1C00F94B1D /* SplitDemo */, 49 | AAD5CF7029956D1C00F94B1D /* Products */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | AAD5CF7029956D1C00F94B1D /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | AAD5CF6F29956D1C00F94B1D /* SplitDemo.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | AAD5CF7129956D1C00F94B1D /* SplitDemo */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | AAC4D89C29956F14005FDD5C /* Info.plist */, 65 | AAD5CF8329956E3300F94B1D /* SplitDemo.entitlements */, 66 | AAD5CF7229956D1C00F94B1D /* SplitDemo.swift */, 67 | AAD5CF7429956D1C00F94B1D /* DemoApp.swift */, 68 | AAF9E0C52996F5CE00EDD306 /* DemoModel.swift */, 69 | AAF9E0C92996F85A00EDD306 /* DemoSplitter.swift */, 70 | AAF9E0C72996F80900EDD306 /* DemoToolbar.swift */, 71 | AAD5CF7629956D1D00F94B1D /* Assets.xcassets */, 72 | AAD5CF7829956D1D00F94B1D /* Preview Content */, 73 | ); 74 | path = SplitDemo; 75 | sourceTree = ""; 76 | }; 77 | AAD5CF7829956D1D00F94B1D /* Preview Content */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | AAD5CF7929956D1D00F94B1D /* Preview Assets.xcassets */, 81 | ); 82 | path = "Preview Content"; 83 | sourceTree = ""; 84 | }; 85 | /* End PBXGroup section */ 86 | 87 | /* Begin PBXNativeTarget section */ 88 | AAD5CF6E29956D1C00F94B1D /* SplitDemo */ = { 89 | isa = PBXNativeTarget; 90 | buildConfigurationList = AAD5CF7D29956D1D00F94B1D /* Build configuration list for PBXNativeTarget "SplitDemo" */; 91 | buildPhases = ( 92 | AAD5CF6B29956D1C00F94B1D /* Sources */, 93 | AAD5CF6C29956D1C00F94B1D /* Frameworks */, 94 | AAD5CF6D29956D1C00F94B1D /* Resources */, 95 | ); 96 | buildRules = ( 97 | ); 98 | dependencies = ( 99 | ); 100 | name = SplitDemo; 101 | packageProductDependencies = ( 102 | AAD5CF8129956D8500F94B1D /* SplitView */, 103 | ); 104 | productName = Example; 105 | productReference = AAD5CF6F29956D1C00F94B1D /* SplitDemo.app */; 106 | productType = "com.apple.product-type.application"; 107 | }; 108 | /* End PBXNativeTarget section */ 109 | 110 | /* Begin PBXProject section */ 111 | AAD5CF6729956D1C00F94B1D /* Project object */ = { 112 | isa = PBXProject; 113 | attributes = { 114 | BuildIndependentTargetsInParallel = 1; 115 | LastSwiftUpdateCheck = 1420; 116 | LastUpgradeCheck = 1520; 117 | TargetAttributes = { 118 | AAD5CF6E29956D1C00F94B1D = { 119 | CreatedOnToolsVersion = 14.2; 120 | }; 121 | }; 122 | }; 123 | buildConfigurationList = AAD5CF6A29956D1C00F94B1D /* Build configuration list for PBXProject "SplitDemo" */; 124 | compatibilityVersion = "Xcode 14.0"; 125 | developmentRegion = en; 126 | hasScannedForEncodings = 0; 127 | knownRegions = ( 128 | en, 129 | Base, 130 | ); 131 | mainGroup = AAD5CF6629956D1C00F94B1D; 132 | packageReferences = ( 133 | AAD5CF8029956D8500F94B1D /* XCRemoteSwiftPackageReference "SplitView" */, 134 | ); 135 | productRefGroup = AAD5CF7029956D1C00F94B1D /* Products */; 136 | projectDirPath = ""; 137 | projectRoot = ""; 138 | targets = ( 139 | AAD5CF6E29956D1C00F94B1D /* SplitDemo */, 140 | ); 141 | }; 142 | /* End PBXProject section */ 143 | 144 | /* Begin PBXResourcesBuildPhase section */ 145 | AAD5CF6D29956D1C00F94B1D /* Resources */ = { 146 | isa = PBXResourcesBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | AAD5CF7A29956D1D00F94B1D /* Preview Assets.xcassets in Resources */, 150 | AAD5CF7729956D1D00F94B1D /* Assets.xcassets in Resources */, 151 | ); 152 | runOnlyForDeploymentPostprocessing = 0; 153 | }; 154 | /* End PBXResourcesBuildPhase section */ 155 | 156 | /* Begin PBXSourcesBuildPhase section */ 157 | AAD5CF6B29956D1C00F94B1D /* Sources */ = { 158 | isa = PBXSourcesBuildPhase; 159 | buildActionMask = 2147483647; 160 | files = ( 161 | AAF9E0CA2996F85A00EDD306 /* DemoSplitter.swift in Sources */, 162 | AAF9E0C82996F80900EDD306 /* DemoToolbar.swift in Sources */, 163 | AAD5CF7529956D1C00F94B1D /* DemoApp.swift in Sources */, 164 | AAF9E0C62996F5CE00EDD306 /* DemoModel.swift in Sources */, 165 | AAD5CF7329956D1C00F94B1D /* SplitDemo.swift in Sources */, 166 | ); 167 | runOnlyForDeploymentPostprocessing = 0; 168 | }; 169 | /* End PBXSourcesBuildPhase section */ 170 | 171 | /* Begin XCBuildConfiguration section */ 172 | AAD5CF7B29956D1D00F94B1D /* Debug */ = { 173 | isa = XCBuildConfiguration; 174 | buildSettings = { 175 | ALWAYS_SEARCH_USER_PATHS = NO; 176 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 177 | CLANG_ANALYZER_NONNULL = YES; 178 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 179 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 180 | CLANG_ENABLE_MODULES = YES; 181 | CLANG_ENABLE_OBJC_ARC = YES; 182 | CLANG_ENABLE_OBJC_WEAK = YES; 183 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 184 | CLANG_WARN_BOOL_CONVERSION = YES; 185 | CLANG_WARN_COMMA = YES; 186 | CLANG_WARN_CONSTANT_CONVERSION = YES; 187 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 188 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 189 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 190 | CLANG_WARN_EMPTY_BODY = YES; 191 | CLANG_WARN_ENUM_CONVERSION = YES; 192 | CLANG_WARN_INFINITE_RECURSION = YES; 193 | CLANG_WARN_INT_CONVERSION = YES; 194 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 195 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 196 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 197 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 198 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 199 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 200 | CLANG_WARN_STRICT_PROTOTYPES = YES; 201 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 202 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 203 | CLANG_WARN_UNREACHABLE_CODE = YES; 204 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 205 | COPY_PHASE_STRIP = NO; 206 | DEBUG_INFORMATION_FORMAT = dwarf; 207 | ENABLE_STRICT_OBJC_MSGSEND = YES; 208 | ENABLE_TESTABILITY = YES; 209 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 210 | GCC_C_LANGUAGE_STANDARD = gnu11; 211 | GCC_DYNAMIC_NO_PIC = NO; 212 | GCC_NO_COMMON_BLOCKS = YES; 213 | GCC_OPTIMIZATION_LEVEL = 0; 214 | GCC_PREPROCESSOR_DEFINITIONS = ( 215 | "DEBUG=1", 216 | "$(inherited)", 217 | ); 218 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 219 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 220 | GCC_WARN_UNDECLARED_SELECTOR = YES; 221 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 222 | GCC_WARN_UNUSED_FUNCTION = YES; 223 | GCC_WARN_UNUSED_VARIABLE = YES; 224 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 225 | MACOSX_DEPLOYMENT_TARGET = 12.4; 226 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 227 | MTL_FAST_MATH = YES; 228 | ONLY_ACTIVE_ARCH = YES; 229 | SDKROOT = iphoneos; 230 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 231 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 232 | }; 233 | name = Debug; 234 | }; 235 | AAD5CF7C29956D1D00F94B1D /* Release */ = { 236 | isa = XCBuildConfiguration; 237 | buildSettings = { 238 | ALWAYS_SEARCH_USER_PATHS = NO; 239 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 240 | CLANG_ANALYZER_NONNULL = YES; 241 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 242 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 243 | CLANG_ENABLE_MODULES = YES; 244 | CLANG_ENABLE_OBJC_ARC = YES; 245 | CLANG_ENABLE_OBJC_WEAK = YES; 246 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 247 | CLANG_WARN_BOOL_CONVERSION = YES; 248 | CLANG_WARN_COMMA = YES; 249 | CLANG_WARN_CONSTANT_CONVERSION = YES; 250 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 251 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 252 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 253 | CLANG_WARN_EMPTY_BODY = YES; 254 | CLANG_WARN_ENUM_CONVERSION = YES; 255 | CLANG_WARN_INFINITE_RECURSION = YES; 256 | CLANG_WARN_INT_CONVERSION = YES; 257 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 258 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 259 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 260 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 261 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 262 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 263 | CLANG_WARN_STRICT_PROTOTYPES = YES; 264 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 265 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 266 | CLANG_WARN_UNREACHABLE_CODE = YES; 267 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 268 | COPY_PHASE_STRIP = NO; 269 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 270 | ENABLE_NS_ASSERTIONS = NO; 271 | ENABLE_STRICT_OBJC_MSGSEND = YES; 272 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 273 | GCC_C_LANGUAGE_STANDARD = gnu11; 274 | GCC_NO_COMMON_BLOCKS = YES; 275 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 276 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 277 | GCC_WARN_UNDECLARED_SELECTOR = YES; 278 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 279 | GCC_WARN_UNUSED_FUNCTION = YES; 280 | GCC_WARN_UNUSED_VARIABLE = YES; 281 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 282 | MACOSX_DEPLOYMENT_TARGET = 12.4; 283 | MTL_ENABLE_DEBUG_INFO = NO; 284 | MTL_FAST_MATH = YES; 285 | SDKROOT = iphoneos; 286 | SWIFT_COMPILATION_MODE = wholemodule; 287 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 288 | VALIDATE_PRODUCT = YES; 289 | }; 290 | name = Release; 291 | }; 292 | AAD5CF7E29956D1D00F94B1D /* Debug */ = { 293 | isa = XCBuildConfiguration; 294 | buildSettings = { 295 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 296 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 297 | CODE_SIGN_ENTITLEMENTS = SplitDemo/SplitDemo.entitlements; 298 | CODE_SIGN_STYLE = Automatic; 299 | CURRENT_PROJECT_VERSION = 1; 300 | DEVELOPMENT_ASSET_PATHS = "\"SplitDemo/Preview Content\""; 301 | DEVELOPMENT_TEAM = ""; 302 | ENABLE_PREVIEWS = YES; 303 | GENERATE_INFOPLIST_FILE = YES; 304 | INFOPLIST_FILE = SplitDemo/Info.plist; 305 | INFOPLIST_KEY_CFBundleDisplayName = "SplitView Demo"; 306 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 307 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 308 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 309 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 310 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 311 | LD_RUNPATH_SEARCH_PATHS = ( 312 | "$(inherited)", 313 | "@executable_path/Frameworks", 314 | ); 315 | MACOSX_DEPLOYMENT_TARGET = 12.4; 316 | MARKETING_VERSION = 1.0; 317 | PRODUCT_BUNDLE_IDENTIFIER = com.stevengharris.SplitDemo; 318 | PRODUCT_NAME = "$(TARGET_NAME)"; 319 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 320 | SUPPORTS_MACCATALYST = YES; 321 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 322 | SWIFT_EMIT_LOC_STRINGS = YES; 323 | SWIFT_STRICT_CONCURRENCY = complete; 324 | SWIFT_VERSION = 5.0; 325 | TARGETED_DEVICE_FAMILY = "1,2,6"; 326 | }; 327 | name = Debug; 328 | }; 329 | AAD5CF7F29956D1D00F94B1D /* Release */ = { 330 | isa = XCBuildConfiguration; 331 | buildSettings = { 332 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 333 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 334 | CODE_SIGN_ENTITLEMENTS = SplitDemo/SplitDemo.entitlements; 335 | CODE_SIGN_STYLE = Automatic; 336 | CURRENT_PROJECT_VERSION = 1; 337 | DEVELOPMENT_ASSET_PATHS = "\"SplitDemo/Preview Content\""; 338 | DEVELOPMENT_TEAM = ""; 339 | ENABLE_PREVIEWS = YES; 340 | GENERATE_INFOPLIST_FILE = YES; 341 | INFOPLIST_FILE = SplitDemo/Info.plist; 342 | INFOPLIST_KEY_CFBundleDisplayName = "SplitView Demo"; 343 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 344 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 345 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 346 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 347 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 348 | LD_RUNPATH_SEARCH_PATHS = ( 349 | "$(inherited)", 350 | "@executable_path/Frameworks", 351 | ); 352 | MACOSX_DEPLOYMENT_TARGET = 12.4; 353 | MARKETING_VERSION = 1.0; 354 | PRODUCT_BUNDLE_IDENTIFIER = com.stevengharris.SplitDemo; 355 | PRODUCT_NAME = "$(TARGET_NAME)"; 356 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 357 | SUPPORTS_MACCATALYST = YES; 358 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 359 | SWIFT_EMIT_LOC_STRINGS = YES; 360 | SWIFT_STRICT_CONCURRENCY = complete; 361 | SWIFT_VERSION = 5.0; 362 | TARGETED_DEVICE_FAMILY = "1,2,6"; 363 | }; 364 | name = Release; 365 | }; 366 | /* End XCBuildConfiguration section */ 367 | 368 | /* Begin XCConfigurationList section */ 369 | AAD5CF6A29956D1C00F94B1D /* Build configuration list for PBXProject "SplitDemo" */ = { 370 | isa = XCConfigurationList; 371 | buildConfigurations = ( 372 | AAD5CF7B29956D1D00F94B1D /* Debug */, 373 | AAD5CF7C29956D1D00F94B1D /* Release */, 374 | ); 375 | defaultConfigurationIsVisible = 0; 376 | defaultConfigurationName = Release; 377 | }; 378 | AAD5CF7D29956D1D00F94B1D /* Build configuration list for PBXNativeTarget "SplitDemo" */ = { 379 | isa = XCConfigurationList; 380 | buildConfigurations = ( 381 | AAD5CF7E29956D1D00F94B1D /* Debug */, 382 | AAD5CF7F29956D1D00F94B1D /* Release */, 383 | ); 384 | defaultConfigurationIsVisible = 0; 385 | defaultConfigurationName = Release; 386 | }; 387 | /* End XCConfigurationList section */ 388 | 389 | /* Begin XCRemoteSwiftPackageReference section */ 390 | AAD5CF8029956D8500F94B1D /* XCRemoteSwiftPackageReference "SplitView" */ = { 391 | isa = XCRemoteSwiftPackageReference; 392 | repositoryURL = "https://github.com/stevengharris/SplitView.git"; 393 | requirement = { 394 | branch = main; 395 | kind = branch; 396 | }; 397 | }; 398 | /* End XCRemoteSwiftPackageReference section */ 399 | 400 | /* Begin XCSwiftPackageProductDependency section */ 401 | AAD5CF8129956D8500F94B1D /* SplitView */ = { 402 | isa = XCSwiftPackageProductDependency; 403 | package = AAD5CF8029956D8500F94B1D /* XCRemoteSwiftPackageReference "SplitView" */; 404 | productName = SplitView; 405 | }; 406 | /* End XCSwiftPackageProductDependency section */ 407 | }; 408 | rootObject = AAD5CF6729956D1C00F94B1D /* Project object */; 409 | } 410 | -------------------------------------------------------------------------------- /Demo/SplitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/SplitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/SplitDemo.xcodeproj/xcshareddata/xcschemes/SplitDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Demo/SplitDemo/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/SplitDemo/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/SplitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SplitDemo/DemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoApp.swift 3 | // Example 4 | // 5 | // Created by Steven Harris on 2/4/23. 6 | // 7 | 8 | import SwiftUI 9 | import SplitView 10 | 11 | struct DemoApp: View { 12 | @State private var demoID: DemoID = .simpleDefaults 13 | 14 | var body: some View { 15 | let demo = demos[demoID]! 16 | VStack { 17 | DemoToolbar(demoID: $demoID) 18 | switch demoID { 19 | case .simpleDefaults: 20 | HSplit( 21 | left: { Color.green }, 22 | right: { Color.red } 23 | ) 24 | case .simpleAdjustable: 25 | Split( 26 | primary: { Color.green }, 27 | secondary: { Color.red } 28 | ) 29 | .styling(color: .yellow) 30 | .layout(demo.holders[0].layout) 31 | .hide(demo.holders[0].hide) 32 | case .nestedAdjustable: 33 | Split( 34 | primary: { Color.green }, 35 | secondary: { 36 | Split( 37 | primary: { Color.red }, 38 | secondary: { 39 | Split( 40 | primary: { Color.blue }, 41 | secondary: { Color.yellow } 42 | ) 43 | .constraints(minPFraction: 0.2, minSFraction: 0.1) 44 | .styling(hideSplitter: true) 45 | .layout(demo.holders[2].layout) 46 | .hide(demo.holders[2].hide) 47 | } 48 | ) 49 | .styling(hideSplitter: true) 50 | .layout(demo.holders[1].layout) 51 | .hide(demo.holders[1].hide) 52 | } 53 | ) 54 | .styling(hideSplitter: true) 55 | .layout(demo.holders[0].layout) 56 | .hide(demo.holders[0].hide) 57 | case .invisibleSplitter: 58 | Split( 59 | primary: { Color.green }, 60 | secondary: { Color.red } 61 | ) 62 | .splitter { Splitter.invisible() } 63 | .constraints(minPFraction: 0.2, minSFraction: 0.2, dragToHideS: true) 64 | .layout(demo.holders[0].layout) 65 | .hide(demo.holders[0].hide) 66 | case .customSplitter: 67 | let layout0 = demo.holders[0].layout 68 | let hide0 = demo.holders[0].hide 69 | let styling = SplitStyling(visibleThickness: 20) 70 | Split( 71 | primary: { Color.green }, 72 | secondary: { Color.red } 73 | ) 74 | .splitter { DemoSplitter(layout: layout0, hide: hide0, styling: styling) } 75 | .layout(layout0) 76 | .hide(hide0) 77 | case .sidebars: 78 | let leftItems = ["Master Item 1", "Master Item 2", "Master Item 3", "Master Item 4"] 79 | let middleText = "Note how each sidebar can be resized without affecting the other one, and how the window can be resized while both sidebars remain the same size." 80 | let rightText = "Here is some metadata about what's showing in the middle that you want to hide/show." 81 | Split( 82 | primary: { 83 | List(leftItems, id: \.self) { item in 84 | Text(item) 85 | .lineLimit(1) 86 | .truncationMode(.tail) 87 | } 88 | .listStyle(.plain) 89 | .padding([.top], 8) 90 | }, 91 | secondary: { 92 | Split( 93 | primary: { 94 | VStack { 95 | Text(middleText) 96 | .frame(maxWidth: .infinity, alignment: .leading) 97 | Spacer() 98 | } 99 | .padding(8) 100 | }, 101 | secondary: { 102 | VStack { 103 | Text(rightText) 104 | .frame(maxWidth: .infinity, alignment: .leading) 105 | Spacer() 106 | } 107 | .padding(8) 108 | } 109 | ) 110 | .splitter { Splitter.line() } 111 | .constraints(minPFraction: 0.3, minSFraction: 0.2, priority: .secondary, dragToHideS: true) 112 | .layout(demo.holders[0].layout) 113 | .fraction(0.75) 114 | .hide(demo.holders[0].hide) 115 | } 116 | ) 117 | .splitter { Splitter.line() } 118 | .constraints(minPFraction: 0.15, minSFraction: 0.15, priority: .primary) 119 | .layout(demo.holders[0].layout) 120 | .fraction(0.2) 121 | .border(.black) 122 | .padding([.leading, .trailing], 8) 123 | } 124 | Text(demo.description) 125 | .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) 126 | } 127 | } 128 | 129 | } 130 | 131 | struct DemoApp_Previews: PreviewProvider { 132 | static var previews: some View { 133 | DemoApp() 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Demo/SplitDemo/DemoModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoModel.swift 3 | // Example 4 | // 5 | // Created by Steven Harris on 2/10/23. 6 | // 7 | 8 | import Foundation 9 | import SplitView 10 | 11 | /// Identifiers for the demos 12 | enum DemoID: String, CaseIterable { 13 | case simpleDefaults 14 | case simpleAdjustable 15 | case nestedAdjustable 16 | case invisibleSplitter 17 | case customSplitter 18 | case sidebars 19 | } 20 | 21 | /// Globally accessible dictionary of Demos. 22 | /// 23 | /// The `label` is used for the Menu, `description` for the Text at the bottom. 24 | /// 25 | /// The `holders` identify the LayoutHolder and SideHolders that can be used to control 26 | /// the various Split views in the demo. These need to be defined once, so that the 27 | /// DemoToolbar at the top and the individual Split views are holding onto the same 28 | /// ObservableObject. 29 | let demos: [DemoID : Demo] = [ 30 | .simpleDefaults : 31 | Demo( 32 | label: "Simple defaults", 33 | description: "Split view with the default Splitter." 34 | ), 35 | .simpleAdjustable : 36 | Demo( 37 | label: "Simple adjustable", 38 | description: "Adjustable split view with yellow default Splitter.", 39 | holders: [SplitStateHolder(layout: LayoutHolder(), hide: SideHolder())] 40 | ), 41 | .nestedAdjustable : 42 | Demo( 43 | label: "Nested adjustable", 44 | description: "Nested adjustable split views with the default Splitter that hides.", 45 | holders: [ 46 | SplitStateHolder(layout: LayoutHolder(), hide: SideHolder()), 47 | SplitStateHolder(layout: LayoutHolder(.vertical), hide: SideHolder()), 48 | SplitStateHolder(layout: LayoutHolder(.horizontal), hide: SideHolder()), 49 | ] 50 | ), 51 | .invisibleSplitter: 52 | Demo( 53 | label: "Invisible splitter", 54 | description: "Invisible splitter with constraints, drag-to-hide on right/bottom.", 55 | holders: [SplitStateHolder(layout: LayoutHolder(), hide: SideHolder())] 56 | ), 57 | .customSplitter: 58 | Demo( 59 | label: "Custom splitter", 60 | description: "Custom splitter that adjusts to layout/hide.", 61 | holders: [SplitStateHolder(layout: LayoutHolder(.horizontal), hide: SideHolder())] 62 | ), 63 | .sidebars: 64 | Demo( 65 | label: "Sidebars", 66 | description: "Opposing sidebar maintains size as either is resized, drag-to-hide on right/bottom.", 67 | holders: [SplitStateHolder(layout: LayoutHolder(.horizontal), hide: SideHolder())] 68 | ), 69 | 70 | ] 71 | 72 | /// Demo holds onto the labels for the Menu and descriptions for the Text at the bottom, along with 73 | /// the SplitStateHolders that are used by the DemoToolbar buttons and the Split views themselves. 74 | struct Demo { 75 | var label: String 76 | var description: String 77 | var holders: [SplitStateHolder] = [] 78 | } 79 | 80 | /// The combination of LayoutHolder and SideHolder used in one Split view. 81 | /// 82 | /// Has to be Identifiable because we ForEach over the `demo.holders` to create the 83 | /// Buttons dynamically. 84 | struct SplitStateHolder: Identifiable { 85 | var id: UUID = UUID() 86 | var layout: LayoutHolder 87 | var hide: SideHolder 88 | } 89 | 90 | -------------------------------------------------------------------------------- /Demo/SplitDemo/DemoSplitter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoSplitter.swift 3 | // Example 4 | // 5 | // Created by Steven Harris on 2/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import SplitView 10 | 11 | /// A custom splitter for the `.customSplitter` demo. 12 | /// 13 | /// Note a custom splitter must conform to SplitDivider protocol, which means it is a View that can tell the 14 | /// Split view what its `styling` is. The `styling` contains the `visibleThickness` as one property. 15 | /// The Split view separates the `primary` and `secondary` views by the `styling.visibleThickness` 16 | /// of the SplitDivider. 17 | /// 18 | /// A custom splitter should be sensitive to the layout, because if the layout changes, then the splitter needs to 19 | /// change. Similarly, your splitter can react to changes in the SideHolder if needed. You can see an example 20 | /// of these behaviors in DemoSplitter. 21 | /// 22 | /// Like the default Splitter, the DemoSplitter uses a clear Color at the bottom of a ZStack to define what its 23 | /// boundaries are, so that the drag gestures respond within this clear Color and the rectangle of the ZStack. 24 | /// 25 | /// Note that the `onHover` cursor change is only applied to the Color.clear, not to the embedded button to 26 | /// hide/show the view. You would want to use a similar technique if your custom splitter has areas your user 27 | /// interacts with. See the splitter between the editing area and the debug/console in Xcode as an example. 28 | struct DemoSplitter: SplitDivider { 29 | @ObservedObject var layout: LayoutHolder 30 | @ObservedObject var hide: SideHolder 31 | @ObservedObject var styling: SplitStyling 32 | /// The `hideButton` state tells whether the custom splitter hides the button that normally shows 33 | /// in the middle. If `styling.previewHide` is true, then we only want to show the button if 34 | /// `styling.hideSplitter` is also true. See the README for more information about drag-to-hide. 35 | /// In general, people using a custom splitter need to handle the layout when `previewHide` 36 | /// is triggered and that layout may depend on whether `hideSplitter` is `true`. 37 | @State private var hideButton: Bool = false 38 | let hideRight = Image(systemName: "arrowtriangle.right.square") 39 | let hideLeft = Image(systemName: "arrowtriangle.left.square") 40 | let hideDown = Image(systemName: "arrowtriangle.down.square") 41 | let hideUp = Image(systemName: "arrowtriangle.up.square") 42 | 43 | var body: some View { 44 | if layout.isHorizontal { 45 | ZStack { 46 | Color.clear 47 | .frame(width: 30) 48 | .padding(0) 49 | .onHover { inside in 50 | #if targetEnvironment(macCatalyst) || os(macOS) 51 | // With nested split views, it's possible to transition from one Splitter to another, 52 | // so we always need to pop the current cursor (a no-op when it's the only one). We 53 | // may or may not push the hover cursor depending on whether it's inside or not. 54 | NSCursor.pop() 55 | if inside { 56 | NSCursor.resizeLeftRight.push() 57 | } 58 | #endif 59 | } 60 | if !hideButton { 61 | Button( 62 | action: { withAnimation { hide.toggle() } }, 63 | label: { 64 | hide.side == nil ? hideRight.imageScale(.large) : hideLeft.imageScale(.large) 65 | } 66 | ) 67 | .buttonStyle(.borderless) 68 | } 69 | } 70 | .contentShape(Rectangle()) 71 | .onChange(of: styling.previewHide) { hide in 72 | hideButton = styling.hideSplitter 73 | } 74 | } else { 75 | ZStack { 76 | Color.clear 77 | .frame(height: 30) 78 | .padding(0) 79 | .onHover { inside in 80 | #if targetEnvironment(macCatalyst) || os(macOS) 81 | // With nested split views, it's possible to transition from one Splitter to another, 82 | // so we always need to pop the current cursor (a no-op when it's the only one). We 83 | // may or may not push the hover cursor depending on whether it's inside or not. 84 | NSCursor.pop() 85 | if inside { 86 | NSCursor.resizeUpDown.push() 87 | } 88 | #endif 89 | } 90 | if !hideButton { 91 | Button( 92 | action: { withAnimation { hide.toggle() } }, 93 | label: { 94 | hide.side == nil ? hideDown.imageScale(.large) : hideUp.imageScale(.large) 95 | } 96 | ) 97 | .buttonStyle(.borderless) 98 | } 99 | } 100 | .contentShape(Rectangle()) 101 | .onChange(of: styling.previewHide) { hide in 102 | hideButton = styling.hideSplitter 103 | } 104 | } 105 | } 106 | 107 | } 108 | 109 | struct DemoSplitter_Previews: PreviewProvider { 110 | static var previews: some View { 111 | DemoSplitter(layout: LayoutHolder(.horizontal), hide: SideHolder(), styling: SplitStyling(visibleThickness: 20)) 112 | DemoSplitter(layout: LayoutHolder(.vertical), hide: SideHolder(), styling: SplitStyling(visibleThickness: 20)) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Demo/SplitDemo/DemoToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoToolbar.swift 3 | // Example 4 | // 5 | // Created by Steven Harris on 2/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import SplitView 10 | 11 | /// A toolbar that allows us to select the Demo to show and to display the AdjusterButtons for that 12 | /// particular Demo. The AdjusterButtons allow us to adjust the `layout` and `hide` values if 13 | /// the Demo specifies `holders`. 14 | struct DemoToolbar: View { 15 | @Binding var demoID: DemoID 16 | 17 | var body: some View { 18 | let demo = demos[demoID]! 19 | HStack(alignment: .center, spacing: 8) { 20 | Text("Demo:") 21 | Menu(demo.label) { 22 | ForEach(DemoID.allCases, id: \.rawValue) { id in 23 | Button(demos[id]!.label) { 24 | withAnimation { 25 | demoID = id 26 | } 27 | } 28 | } 29 | } 30 | Spacer() 31 | AdjusterButtons(demo: demo) 32 | } 33 | // The alignment between Menu and Buttons is whacky and 34 | // forced me to set frame and insets manually. 35 | .frame(height: 24) 36 | .padding(EdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)) 37 | } 38 | 39 | } 40 | 41 | /// If the `demo` has `holders`, populate an HStack with sets of two buttons for each. 42 | /// One of the buttons lets us hide/show the SplitSide, and the other lets us toggle the 43 | /// `layout`. 44 | struct AdjusterButtons: View { 45 | let demo: Demo 46 | 47 | var body: some View { 48 | let holders = demo.holders 49 | HStack { 50 | ForEach(holders) { holder in 51 | Divider() 52 | AdjustHideButton(layout: holder.layout, hide: holder.hide) 53 | AdjustLayoutButton(layout: holder.layout, hide: holder.hide) 54 | } 55 | } 56 | } 57 | 58 | } 59 | 60 | /// A button to toggle the SideHolder and to indicate the current state using the proper system image. 61 | struct AdjustHideButton: View { 62 | @ObservedObject var layout: LayoutHolder 63 | @ObservedObject var hide: SideHolder 64 | 65 | var body: some View { 66 | Button( 67 | action: { 68 | withAnimation { 69 | hide.toggle() 70 | } 71 | }, 72 | label: { 73 | if hide.side == nil { 74 | Image(systemName: "rectangle.righthalf.inset.filled.arrow.right") 75 | } else { 76 | Image(systemName: "rectangle.lefthalf.inset.filled.arrow.left") 77 | } 78 | } 79 | ) 80 | } 81 | 82 | } 83 | 84 | /// A button to toggle the LayoutHolder and to indicate the current state using the proper system image. 85 | struct AdjustLayoutButton: View { 86 | @ObservedObject var layout: LayoutHolder 87 | @ObservedObject var hide: SideHolder 88 | 89 | var body: some View { 90 | Button( 91 | action: { 92 | withAnimation { 93 | layout.toggle() 94 | } 95 | }, 96 | label: { 97 | layout.isHorizontal ? Image(systemName: "rectangle.split.1x2") : Image(systemName: "rectangle.split.2x1") 98 | } 99 | ) 100 | .disabled(hide.side != nil) 101 | } 102 | 103 | } 104 | 105 | struct DemoToolbar_Previews: PreviewProvider { 106 | static var previews: some View { 107 | ForEach(DemoID.allCases, id: \.rawValue) { demoID in 108 | DemoToolbar(demoID: .constant(demoID)) 109 | } 110 | } 111 | } 112 | 113 | -------------------------------------------------------------------------------- /Demo/SplitDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Demo/SplitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SplitDemo/SplitDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Demo/SplitDemo/SplitDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleApp.swift 3 | // Example 4 | // 5 | // Created by Steven Harris on 2/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct SplitDemo: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | DemoApp() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Steven G. Harris 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SplitView", 7 | platforms: [.macOS(.v12), .iOS(.v15), .macCatalyst(.v15)], 8 | products: [ 9 | .library( 10 | name: "SplitView", 11 | targets: ["SplitView"]), 12 | ], 13 | dependencies: [], 14 | targets: [ 15 | .target( 16 | name: "SplitView", 17 | dependencies: [], 18 | swiftSettings: [ 19 | .enableExperimentalFeature("StrictConcurrency") 20 | ]), 21 | .testTarget(name: "SplitViewTests", dependencies: ["SplitView"]), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | iOS 15.6+ 4 | MacCatalyst 15.6+ 5 | MacCatalyst 12.4+ 6 | 7 | Mastodon: @stevengharris@mastodon.social 8 | 9 |

10 | 11 | # SplitView 12 | 13 | The Split, HSplit, and VSplit views and associated modifiers let you: 14 | 15 | * Create a single view containing two views, arranged in a horizontal (side-by-side) 16 | or vertical (above-and-below) `layout` separated by a draggable `splitter` for resizing. 17 | * Specify the `fraction` of full width/height for the initial position of the splitter. 18 | * Programmatically `hide` either view and change the `layout`. 19 | * Arbitrarily nest split views. 20 | * Constrain the splitter movement by specifying minimum fractions of the full width/height 21 | for either or both views. 22 | * Drag-to-hide, so when you constrain the fraction on a side, you can hide the side 23 | when you drag more than halfway beyond the constraint. 24 | * Prioritize either of one the views to maintain its width/height as the containing 25 | view changes size. 26 | * Easily save the state of `fraction`, `layout`, and `hide` so a split view opens 27 | in its last state between application restarts. 28 | * Use your own custom `splitter` or the default Splitter. 29 | * Make splitters "invisible" (i.e., zero `visibleThickness`) but still draggable for 30 | resizing. 31 | * Monitor splitter movement in realtime, providing a simple way to create a custom slider. 32 | 33 | ## Motivation 34 | 35 | NavigationSplitView is fine for a sidebar and for applications that conform to a 36 | nice master-detail type of model. On the other hand, sometimes you just need two 37 | views to sit side-by-side or above-and-below each other and to adjust the split 38 | between them. You also might want to compose split views in ways that make sense 39 | in your own application context. 40 | 41 | ## Demo 42 | 43 | ![SplitView](https://user-images.githubusercontent.com/1020361/219515082-6e657bee-e4e2-4efd-9e78-f5c98aaa3083.mov) 44 | 45 | This demo is available in the Demo directory as SplitDemo.xcodeproj. 46 | 47 | ## Usage 48 | 49 | Install the package. 50 | 51 | * To split two views horizontally, use an HSplit view. 52 | * To split two views vertically, use a VSplit view. 53 | * To split two views whose layout can be changed between horizontal and vertical, 54 | use a Split view. 55 | 56 | **Note:** You can also use the `.split`, `.vSplit`, and `.hSplit` view modifiers that come 57 | with the package to create a Split, VSplit, and HSplit view if that makes more sense to you. 58 | See the discussion in [Style](#style). 59 | 60 | Once you have created a Split, HSplit, or VSplit view, you can use view modifiers on them 61 | to: 62 | 63 | * Specify the initial fraction of the overall width/height that the left/top side should occupy. 64 | * Identify a side that can be hidden and unhidden. 65 | * Adjust the style of the default Splitter, including its color and thickness. 66 | * Place constraints on the minimum fraction each side occupies and which side should be 67 | prioritized (i.e., remain fixed in size) as the containing view's size changes. 68 | * Provide a custom splitter. 69 | * Be able to toggle layout between horizontal and vertical. This modifier is only 70 | available for the Split view, since HSplit and VSplit remain in a horizontal or 71 | vertical layout by definition. 72 | 73 | In its simplest form, the HSplit and VSplit views look like this: 74 | 75 | ``` 76 | HSplit(left: { Color.red }, right: { Color.green }) 77 | VSplit(top: { Color.red }, bottom: { Color.green }) 78 | ``` 79 | 80 | The HSplit is a horizontal split view, evenly split between red on the left and 81 | green on the right. The VSplit is a vertical split view, evenly split between red 82 | on the top and green on the bottom. Both views use a default splitter between them 83 | that can be dragged to change the red and green view sizes. 84 | 85 | If you want to set the the initial position of the splitter, you can use the 86 | `fraction` modifier. Here it is being used with a VSplit view: 87 | 88 | ``` 89 | VSplit(top: { Color.red }, bottom: { Color.green }) 90 | .fraction(0.25) 91 | ``` 92 | 93 | Now you get a red view above the green view, with the top occupying 94 | 1/4 of the window. 95 | 96 | Often you want to hide/show one of the views you split. You can do this by specifying 97 | the side to hide. Specify the side using a SplitSide. For an HSplit view, you can 98 | identify the side using `.left` or `.right`. For a VSplit view, you can use `.top` 99 | or `.bottom`. For a Split view (where the layout can change), use `.primary` or 100 | `.secondary`. In fact, `.left`, `.top`, and `.primary` are all synonyms and can be 101 | used interchangably. Similarly, `.right`, `.bottom`, and `.secondary` are synonyms. 102 | 103 | Here is an HSplit view that hides the right side when it opens: 104 | 105 | ``` 106 | HSplit(left: { Color.red }, right: { Color.green }) 107 | .fraction(0.25) 108 | .hide(.right) 109 | ``` 110 | 111 | The green side will be hidden, but you can pull it open using the splitter that will 112 | be visible on the right. This isn't usually what you want, though. Usually you want 113 | your users to be able to control whether a side is hidden or not. To do this, pass the 114 | SideHolder ObservableObject that holds onto the side you are hiding. Similarly the SplitView 115 | package comes with a FractionHolder and LayoutHolder. Under the covers, the Split view 116 | observes all of these holders and redraws itself if they change. 117 | 118 | Here is an example showing how to use the SideHolder with a Button to hide/show the 119 | right (green) side: 120 | 121 | ``` 122 | struct ContentView: View { 123 | let hide = SideHolder() // By default, don't hide any side 124 | var body: some View { 125 | VStack(spacing: 0) { 126 | Button("Toggle Hide") { 127 | withAnimation { 128 | hide.toggle() // Toggle between hiding nothing and hiding right 129 | } 130 | } 131 | HSplit(left: { Color.red }, right: { Color.green }) 132 | .hide(hide) 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | Note that the `hide` modifier accepts a SplitSide or a SideHolder. Similarly, `layout` 139 | can be passed as a SplitLayout - `.horizontal` or `.vertical` - or as a LayoutHolder. 140 | And `fraction` can be passed as a CGFloat or as a FractionHolder. 141 | 142 | The `toggle()` method on `hide` toggles the hide/show state for the `secondary` side 143 | by default. If you want to toggle the hide/show state for a specific side, then use 144 | `toggle(.primary)` or `toggle(.secondary)` explicitly. (Note that `.primary`, `.left`, 145 | and `.top` are synonyms; and `.secondary`, `.right`, and `.bottom` are synonyms.) 146 | 147 | ### Nesting Split Views 148 | 149 | Split views themselves can be split. Here is an example where the 150 | right side of an HSplit is a VSplit that has an HSplit at the bottom: 151 | 152 | ``` 153 | struct ContentView: View { 154 | var body: some View { 155 | HSplit( 156 | left: { Color.green }, 157 | right: { 158 | VSplit( 159 | top: { Color.red }, 160 | bottom: { 161 | HSplit( 162 | left: { Color.blue }, 163 | right: { Color.yellow } 164 | ) 165 | } 166 | ) 167 | } 168 | ) 169 | } 170 | } 171 | ``` 172 | 173 | And here is one where an HSplit contains two VSplits: 174 | 175 | ``` 176 | struct ContentView: View { 177 | var body: some View { 178 | HSplit( 179 | left: { 180 | VSplit(top: { Color.red }, bottom: { Color.green }) 181 | }, 182 | right: { 183 | VSplit(top: { Color.yellow }, bottom: { Color.blue }) 184 | } 185 | ) 186 | } 187 | } 188 | ``` 189 | 190 | ### Using UserDefaults For Split State 191 | 192 | The three holders - SideHolder, LayoutHolder, and FractionHolder - all come with a 193 | static method to return instances that get/set their state from UserDefaults.standard. 194 | Let's expand the previous example to be able to change the `layout` and `hide` state 195 | and to get/set their values from UserDefaults. Note that if you want to adjust the 196 | `layout`, you need to use a Split view, not HSplit or VSplit. We create the Split view 197 | by specifying the `primary` and `secondary` views. When the SplitLayout held by the 198 | LayoutHolder (`layout`) is `.horizontal`, the `primary` view is on the left side, and 199 | the `secondary` view is on the right. When the SplitLayout toggles to `vertical`, the 200 | `primary` view is on the top, and the `secondary` view is on the bottom. 201 | 202 | ``` 203 | struct ContentView: View { 204 | let fraction = FractionHolder.usingUserDefaults(0.5, key: "myFraction") 205 | let layout = LayoutHolder.usingUserDefaults(.horizontal, key: "myLayout") 206 | let hide = SideHolder.usingUserDefaults(key: "mySide") 207 | var body: some View { 208 | VStack(spacing: 0) { 209 | HStack { 210 | Button("Toggle Layout") { 211 | withAnimation { 212 | layout.toggle() 213 | } 214 | } 215 | Button("Toggle Hide") { 216 | withAnimation { 217 | hide.toggle() 218 | } 219 | } 220 | } 221 | Split(primary: { Color.red }, secondary: { Color.green }) 222 | .fraction(fraction) 223 | .layout(layout) 224 | .hide(hide) 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | The first time you open this, the sides will be split 50-50, but as you drag the 231 | splitter, the `fraction` state is also retained in UserDefaults.standard. 232 | You can change the `layout` and hide/show the green view, and when you next open 233 | the app, the `fraction`, `hide`, and `layout` will all be restored how you left them. 234 | 235 | ### Modifying And Constraining The Default Splitter 236 | 237 | You can change the way the default Splitter displays using the `styling` modifier. 238 | For example, you can change the color, inset, and thickness: 239 | 240 | ``` 241 | HSplit(left: { Color.red }, right: { Color.green }) 242 | .fraction(0.25) 243 | .styling(color: Color.cyan, inset: 4, visibleThickness: 8) 244 | ``` 245 | 246 | If you prefer the splitter to hide also when you hide a side, you can set `hideSplitter` 247 | to `true` in the `styling` modifier. For example: 248 | 249 | ``` 250 | HSplit(left: { Color.red }, right: { Color.green }) 251 | .styling(hideSplitter: true) 252 | ``` 253 | 254 | Note that if you set `hideSplitter` to `true`, you need to include a means for 255 | your user to unhide a view once it is hidden, like a hide/show button. That's 256 | because the splitter itself isn't displayed at all, so you can't just drag it out 257 | from the side. 258 | 259 | By default, the splitter can be dragged across the full width/height of the split 260 | view. The `constraints` modifier lets you constrain the minimum faction of the 261 | overall view that the "primary" and/or "secondary" view occupies, so the 262 | splitter always stays within those constraints. You can do this by specifying 263 | `minPFraction` and/or `minSFraction`. The `minPFraction` refers to left 264 | in HSplit and top in VSplit, while `minSFraction` refers to right in HSplit and 265 | bottom in VSplit: 266 | 267 | ``` 268 | HSplit(left: { Color.red }, right: { Color.green }) 269 | .fraction(0.3) 270 | .constraints(minPFraction: 0.2, minSFraction: 0.2) 271 | ``` 272 | 273 | ### Drag-To-Hide 274 | 275 | When you constrain the fraction of the primary or secondary side, you may want the 276 | side to hide automatically when you drag past the constraint. However, we need to 277 | trigger this drag-to-hide behavior when you drag "well past" the constraint, because 278 | otherwise, it's difficult to leave the splitter positioned at the constraint without 279 | hiding it. For this reason, a split view defines "well past" to mean "more than 280 | halfway past the contraint". 281 | 282 | Drag-to-hide can be a nice shortcut to avoid having to press a button to hide a side. 283 | You can see an example of it in Xcode when you drag the splitter between the editor area 284 | in the middle and the Inspector on the right beyond the constraint Xcode puts on the 285 | Inspector width. In Xcode, when you drag-to-hide the splitter between the editor area 286 | and the Inspector, you cannot drag it back out because the splitter itself is hidden. 287 | You need a button to invoke the hide/show action, as discussed 288 | [earlier](#modifying-and-constraining-the-default-splitter). The same is true with 289 | drag-to-hide using a split view when `hideSplitter` is `true`. 290 | 291 | When your cursor moves beyond the halfway point of the constrained side, the split view 292 | previews what it will look like when the side is hidden. This way, you have a visual indication 293 | that the side will hide, and you can drag back out to avoid hiding it. If your dragging ends 294 | when the side is hidden, then it will remain hidden. 295 | 296 | Note that when you use drag-to-hide, the splitter may or may not be hidden when the side is 297 | hidden (depending on whether `hideSplitter` is `true` in SplitStyling). The preview of what the 298 | split view will look like if you release past the halfway point reflects your choice of setting 299 | for `hideSplitter`. 300 | 301 | To use drag-to-hide, add `dragToHideP` and/or `dragToHideS` to your `constraints` definition. 302 | For example, the following will constrain dragging between 20% and 80% of the width, but 303 | when the drag gesture ends at or beyond the 90% mark on the right, the secondary side will 304 | hide. Note also that in this case, the primary side doesn't use drag-to-hide: 305 | 306 | ``` 307 | HSplit(left: { Color.red }, right: { Color.green }) 308 | .constraints(minPFraction: 0.2, minSFraction: 0.2, dragToHideS: true) 309 | ``` 310 | 311 | ### Custom Splitters 312 | 313 | By default the Split, HSplit, and VSplit views all use the default Splitter view. You can 314 | create your own and use it, though. Your custom splitter should conform to SplitDivider 315 | protocol, which makes sure your custom splitter can let the Split view know what its 316 | `styling` is. The `styling.visibleThickness` is the size your custom splitter displays 317 | itself in, and it also defines the spacing between the `primary` and `secondary` views inside 318 | of Split view. 319 | 320 | The Split view detects drag events occurring in the splitter. For this reason, you might want 321 | to use a ZStack with an underlying Color.clear that represents the `styling.invisibleThickness` 322 | if the `styling.visibleThickness` is too small for properly detecting the drag events. 323 | 324 | Here is an example custom splitter whose contents is sensitive to the observed `layout` 325 | and `hide` state: 326 | 327 | ``` 328 | struct CustomSplitter: SplitDivider { 329 | @ObservedObject var layout: LayoutHolder 330 | @ObservedObject var hide: SideHolder 331 | @ObservedObject var styling: SplitStyling 332 | /// The `hideButton` state tells whether the custom splitter hides the button that normally shows 333 | /// in the middle. If `styling.previewHide` is true, then we only want to show the button if 334 | /// `styling.hideSplitter` is also true. 335 | /// In general, people using a custom splitter need to handle the layout when `previewHide` 336 | /// is triggered and that layout may depend on whether `hideSplitter` is `true`. 337 | @State private var hideButton: Bool = false 338 | let hideRight = Image(systemName: "arrowtriangle.right.square") 339 | let hideLeft = Image(systemName: "arrowtriangle.left.square") 340 | let hideDown = Image(systemName: "arrowtriangle.down.square") 341 | let hideUp = Image(systemName: "arrowtriangle.up.square") 342 | 343 | var body: some View { 344 | if layout.isHorizontal { 345 | ZStack { 346 | Color.clear 347 | .frame(width: 30) 348 | .padding(0) 349 | if !hideButton { 350 | Button( 351 | action: { withAnimation { hide.toggle() } }, 352 | label: { 353 | hide.side == nil ? hideRight.imageScale(.large) : hideLeft.imageScale(.large) 354 | } 355 | ) 356 | .buttonStyle(.borderless) 357 | } 358 | } 359 | .contentShape(Rectangle()) 360 | .onChange(of: styling.previewHide) { hide in 361 | hideButton = styling.hideSplitter 362 | } 363 | } else { 364 | ZStack { 365 | Color.clear 366 | .frame(height: 30) 367 | .padding(0) 368 | if !hideButton { 369 | Button( 370 | action: { withAnimation { hide.toggle() } }, 371 | label: { 372 | hide.side == nil ? hideDown.imageScale(.large) : hideUp.imageScale(.large) 373 | } 374 | ) 375 | .buttonStyle(.borderless) 376 | } 377 | } 378 | .contentShape(Rectangle()) 379 | .onChange(of: styling.previewHide) { hide in 380 | hideButton = styling.hideSplitter 381 | } 382 | } 383 | }} 384 | ``` 385 | 386 | You can use the CustomSplitter like this: 387 | 388 | ``` 389 | struct ContentView: View { 390 | let layout = LayoutHolder() 391 | let hide = SideHolder() 392 | let styling = SplitStyling(visibleThickness: 20) 393 | var body: some View { 394 | Split(primary: { Color.red }, secondary: { Color.green }) 395 | .layout(layout) 396 | .hide(hide) 397 | .splitter { CustomSplitter(layout: layout, hide: hide, styling: styling) } 398 | } 399 | } 400 | ``` 401 | 402 | If you make a custom splitter that would be generally useful to people, consider filing 403 | a pull request for an additional Splitter extension in Splitter+Extensions.swift. 404 | The `line` Splitter is included in the file as an example that is used in the "Sidebars" 405 | demo. Similarly, the `invisible` Splitter re-uses the `line` splitter by passing a 406 | `visibleThickness` of zero and is used in the "Invisible splitter" demo. 407 | 408 | ### Invisible Splitters 409 | 410 | You might want the views you split to be adjustable using the splitter, but for the splitter 411 | itself to be invisible. For example, a "normal" sidebar doesn't show a splitter between itself 412 | and the detail view it sits next to. You can do this by passing `Splitter.invisible()` as the 413 | custom splitter. 414 | 415 | One thing to watch out for with an invisible splitter is that when a side is hidden, there 416 | is no visual indication that it can be dragged back out. To prevent this issue, you should 417 | specify `minPFraction` and `minSFraction` when using `Splitter.invisible()`. 418 | 419 | ``` 420 | struct ContentView: View { 421 | let hide = SideHolder() 422 | var body: some View { 423 | VStack(spacing: 0) { 424 | Button("Toggle Hide") { 425 | withAnimation { 426 | hide.toggle() // Toggle between hiding nothing and hiding secondary 427 | } 428 | } 429 | HSplit(left: { Color.red }, right: { Color.green }) 430 | .hide(hide) 431 | .constraints(minPFraction: 0.2, minSFraction: 0.2) 432 | .splitter { Splitter.invisible() } 433 | } 434 | } 435 | } 436 | ``` 437 | 438 | ### Monitoring And Responding To Splitter Movement 439 | 440 | You can specify a callback for the split view to execute as you drag the splitter. The 441 | callback reports the `privateFraction` being tracked; i.e., the fraction of the full 442 | width/height occupied by the left/top side. Specify the callback using the `onDrag(_:)` 443 | modifier for any of the split views. 444 | 445 | Here is an example of a DemoSlider that uses the `onDrag(_:)` modifier to update 446 | a Text view showing the percentage each side is occupying. 447 | 448 | ``` 449 | struct DemoSlider: View { 450 | @State private var privateFraction: CGFloat = 0.5 451 | var body: some View { 452 | HSplit( 453 | left: { 454 | ZStack { 455 | Color.green 456 | Text(percentString(for: .left)) 457 | } 458 | }, 459 | right: { 460 | ZStack { 461 | Color.red 462 | Text(percentString(for: .right)) 463 | } 464 | } 465 | ) 466 | .onDrag { fraction in privateFraction = fraction } 467 | .frame(width: 400, height: 30) 468 | } 469 | 470 | /// Return a string indicating the percentage occupied by `side` 471 | func percentString(for side: SplitSide) -> String { 472 | var percent: Int 473 | if side.isPrimary { 474 | percent = Int(round(100 * privateFraction)) 475 | } else { 476 | percent = Int(round(100 * (1 - privateFraction))) 477 | } 478 | // Empty string if the side will be too small to show it 479 | return percent < 10 ? "" : "\(percent)%" 480 | } 481 | } 482 | ``` 483 | 484 | It looks like this: 485 | 486 | ![DemoSlider](https://user-images.githubusercontent.com/1020361/231880861-c710dfb8-ada3-41e2-802b-a71d947b867f.mov) 487 | 488 | ### Prioritizing The Size Of A Side 489 | 490 | When you want a sidebar type of arrangement using HSplit views, you often want 491 | the sidebar to maintain its width as you resize the overall view. You might 492 | have the same need with a VSplit, too. If you have two sidebars, you may want 493 | to slide either one while the opposing one stays the same width. You can 494 | accomplish this by specifying a `priority` side (either `.left`/`.right` or 495 | `.top`/`.bottom`) in the `constraints` modifier. 496 | 497 | Here is an example that has a red left sidebar and green right sidebar surrounding a 498 | yellow middle view. As you drag either splitter, the other stays fixed. Under the covers, 499 | the Split view is adjusting the proportion between `primary` and `secondary` to keep the 500 | splitter in the same place. You will also see that as you resize the window, both 501 | sidebars maintain their width. 502 | 503 | ``` 504 | struct ContentView: View { 505 | var body: some View { 506 | HSplit( 507 | left: { Color.red }, 508 | right: { 509 | HSplit( 510 | left: { Color.yellow }, 511 | right: { Color.green } 512 | ) 513 | .fraction(0.75) 514 | .constraints(priority: .right) 515 | } 516 | ) 517 | .fraction(0.2) 518 | .constraints(priority: .left) 519 | } 520 | } 521 | ``` 522 | 523 | Note that in the example above, the two sidebars have the same width, 524 | which is 0.2 of the overall width, even though the fractions specified for the 525 | left and right sides are 0.2 and 0.75 respectively. This is because the left side 526 | of the outer HSplit is 0.2 of the overall width, leaving 0.8 to divide in the inner 527 | HSplit. The left side of the inner HSplit is 0.75\*0.8 or 0.6 of the overall width, 528 | leaving the right side of the inner HSplit to be 0.2 of the overall width. 529 | 530 | ## Implementation 531 | 532 | The heart of the implementation here is the Split view. VSplit and HSplit are really 533 | convenience and clarity wrappers around Split. There is probably not a big need for 534 | most people to be able to adjust layout dynamically, which is really the only reason 535 | to use Split directly. 536 | 537 | Although ultimately Split has to deal in width and height, the math of adjusting the 538 | layout is the same whether its `primary` is at the left or top and its `secondary` is 539 | at the right or bottom. 540 | 541 | The main piece of state that changes in Split view is `constrainedFraction`. This is the 542 | fraction of the overall width/height occupied by the `primary` view. It changes as you 543 | drag the splitter. When you hide/show, it does not change, because it holds the state 544 | needed to restore-to when a hidden view is shown again. The Split view monitors changes 545 | to its size. The size changes when its containing view changes size (e.g., resizing a 546 | window on the Mac or when nested in another Split view whose splitter is dragged). 547 | 548 | The three views, Split, HSplit, and VSplit all support the same modifiers 549 | to adjust `fraction`, `hide`, `styling`, `constraints`, `onDrag`, and `splitter`. 550 | The Split view also has a modifier for `layout` (which is also used by HSplit and 551 | VSplit) and a few convenience modifiers used by HSplit and VSplit. 552 | 553 | ### Style 554 | 555 | After going all-in on a View modifier style to return a single Split-type of view 556 | for any View it is invoked on, I read an 557 | [article by John Sundell](https://swiftbysundell.com/articles/swiftui-views-versus-modifiers/) 558 | that illustrated some of the "problematic" issues associated with view modifiers 559 | creating different container views. As a result, I reconsidered my approach. 560 | I'm still using view modifiers extensively, but now they operate on an explicit 561 | Split, HSplit, or VSplit container, and always return the same type of view they 562 | modify. I think this makes usage a lot more clear in the end. 563 | 564 | If you prefer the idea of a View modifier to kick off your Split, HSplit, or VSplit 565 | creation, you can still use: 566 | 567 | ``` 568 | Color.green.hSplit { Color.red } // Returns an HSplit 569 | Color.green.vSplit { Color.red } // Returns a VSplit 570 | Color.green.split { Color.red } // Returns a Split 571 | ``` 572 | 573 | instead of: 574 | 575 | ``` 576 | HSplit(left: { Color.green }, right: { Color.red } ) 577 | VSplit(top: { Color.green }, bottom: { Color.red } ) 578 | Split(primary: { Color.green }, secondary: { Color.red }) 579 | ``` 580 | 581 | ## Issues 582 | 583 | 1. In versions prior to MacOS 14.0 Sonoma, there is what appears to be a harmless 584 | log message when dragging the Splitter to cause a view size to go to zero on 585 | Mac Catalyst only. The message shows up in the Xcode console as `[API] cannot 586 | add handler to 3 from 3 - dropping`. This message is not present as of MacOS 14.0 Sonoma. 587 | 588 | 2. The Splitter's `onHover` entry action used to display the resizing cursors on Mac Catalyst 589 | and MacOS may occasionally not be triggered when using nested split views. I think this happens 590 | seldom enough to not be a problem. When it occurs, the cursor doesn't change to 591 | `resizeLeftRight` or `resizeUpDown` when hovering over a splitter, but the splitter will 592 | still be draggable. 593 | 594 | ## Possible Enhancements 595 | 596 | I might add a few things but would be very happy to accept pull requests! For example, 597 | a split view that adapted to device orientation and form factors somewhat like 598 | NavigationSplitView would be useful. 599 | 600 | ## History 601 | 602 | ### Version 3.5 603 | 604 | * Publish changes to `fraction`, so that setting the value externally changes the split view layout (Issue [29](https://github.com/stevengharris/SplitView/issues/29)). 605 | * Allow use of `toggle(.primary)` or `toggle(.secondary)` as a way to specify the side to hide/show (thanks [Bastiaan Terhorst](https://github.com/bastiaanterhorst)). 606 | * Fix display bug when specifying `minFractionP` and hiding `primary` side (Issue [31](https://github.com/stevengharris/SplitView/issues/31)). 607 | * Remove forced hiding of splitter when hiding a side with `minFractionP` or `minFractionS` specified (Issue [30](https://github.com/stevengharris/SplitView/issues/30)). 608 | * Update README to reflect changes. 609 | 610 | ### Version 3.4 611 | 612 | * Refactor so that Splitter holds SplitStyling, allowing custom splitters to participate properly in drag-to-hide. 613 | * Incompatible change to SplitDivider protocol to expose `styling: SplitStyling` rather than `visibleThickness`. The incompatibility only affects you if you were using a [custom splitter](#custom-splitters). 614 | 615 | ### Version 3.3 616 | 617 | * Support drag-to-hide with a preview of the side being hidden as you drag beyond the 618 | halfway point of the constrained side. See the [Drag-To-Hide](#drag-to-hide) section. 619 | 620 | ### Version 3.2 621 | 622 | * Display resizing cursors on Mac Catalyst and MacOS when hovering over the splitter. 623 | * Add ability to hide a side when dragging completes at a point beyond the minimum constraints. See the [Drag-To-Hide](#drag-to-hide) section. 624 | * Add ability to hide the splitter when a side is hidden. See the information on `hideSplitter` in the [Modifying And Constraining The Default Splitter](#modifying-and-constraining-the-default-splitter) section. 625 | 626 | ### Version 3.1 627 | 628 | * Add `onDrag` modifier to be able to monitor and respond to splitter movement. See the [Monitoring And Responding To Splitter Movement](#monitoring-and-responding-to-splitter-movement) section. 629 | 630 | ### Version 3.0 631 | 632 | * Incompatible change from Version 2 to change from an extensive set of View modifiers to explicit use of Split, HSplit, and VSplit. Most of the previous version's `split` View modifiers have been removed in this version. 633 | * Modify the DemoApp to use the new Split, HSplit, and VSplit approach. Functionality is unchanged. 634 | 635 | ### Version 2.0 636 | 637 | * Incompatible change from Version 1 in split enums. SplitLayout cases change from `.Horizontal` and `.Vertical` to `.horizontal` and `.vertical`. SplitSide cases change from `.Primary` and `.Secondary` to `.primary` and `.secondary`. 638 | * Add ability to specify a side (`.primary` or `.secondary`) that has sizing `priority`. The size of the `priority` side remains unchanged as its containing view resizes. If `priority` is not specified - the default - then the proportion between `primary` and `secondary` is maintained. This enables proper sidebar type of behavior, where changing one sidebar's size does not affect the other. 639 | * Add a sidebar demo showing the use of `priority`. 640 | 641 | ### Version 1.1 642 | 643 | * Generalize the way configuration of SplitView properties are handled using SplitConfig, which can optionally be passed to the `split` modifier. 644 | There is a minor compatibility change in that properties such as `color` and `visibleThickness` must be passed to the default Splitter using SplitConfig. 645 | * Allow minimum fractions - `minPFraction` and `minSFraction` - to be configured in SplitConfig to constrain the size of the `primary` and/or `secondary` views. 646 | * If a minimum fraction is specified for a side and that side is hidden, then the splitter will be hidden, too. The net effect of this change is 647 | that the hidden side cannot be dragged open when it is hidden and a minimum fraction is specified for a side. It can still be unhidden by 648 | changing its SideHolder. Under these conditions, the unhidden side occupies the full width/height when the other is hidden, without any inset 649 | for the splitter. 650 | 651 | ### Version 1.0 652 | 653 | Make layout adjustable. Clean up and formalize the SplitDemo, including the custom splitter and "invisible" splitter. Update the README. 654 | 655 | ### Version 0.2 656 | 657 | Eliminates the use of the clear background and SizePreferenceKeys. (My suspicion is they were needed earlier because GeometryReader otherwise caused bad behavior, but in any case they are not needed now.) Eliminate HSplitView and VSplitView, which were themselves holding onto a SplitView. The layering was both unnecessary and not adding value other than making it explicit what kind of SplitView was being created. I concluded that the same expression was actually clearer and more concise using ViewModifiers. I also added the Example.xcworkspace. 658 | 659 | ### Version 0.1 660 | 661 | Originally posted in [response](https://stackoverflow.com/a/68926261) to https://stackoverflow.com/q/67403140. This version used HSplitView and VSplitView as a means to create the SplitView. It also used SizePreferenceKeys from a GeometryReader on a clear background to set the size. In nested SplitViews, I found this was causing "Bound preference ... tried to update multiple times per frame" to happen intermittently depending on the view arrangement. 662 | 663 | -------------------------------------------------------------------------------- /Sources/SplitView/HSplit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HSplit.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 3/1/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | public struct HSplit: View { 12 | private let fraction: FractionHolder 13 | private let hide: SideHolder 14 | private let constraints: SplitConstraints 15 | private let onDrag: ((CGFloat)->Void)? 16 | private let primary: P 17 | private let splitter: D 18 | private let secondary: S 19 | 20 | public var body: some View { 21 | Split(primary: { primary }, secondary: { secondary }) 22 | .layout(LayoutHolder(.horizontal)) 23 | .constraints(constraints) 24 | .onDrag(onDrag) 25 | .splitter { splitter } 26 | .fraction(fraction) 27 | .hide(hide) 28 | } 29 | 30 | public init(@ViewBuilder left: @escaping ()->P, @ViewBuilder right: @escaping ()->S) where D == Splitter { 31 | let fraction = FractionHolder() 32 | let hide = SideHolder() 33 | let constraints = SplitConstraints() 34 | self.init(fraction: fraction, hide: hide, constraints: constraints, onDrag: nil, primary: { left() }, splitter: { D() }, secondary: { right() }) 35 | } 36 | 37 | private init(fraction: FractionHolder, hide: SideHolder, constraints: SplitConstraints, onDrag: ((CGFloat)->Void)?, @ViewBuilder primary: @escaping ()->P, @ViewBuilder splitter: @escaping ()->D, @ViewBuilder secondary: @escaping ()->S) { 38 | self.fraction = fraction 39 | self.hide = hide 40 | self.constraints = constraints 41 | self.onDrag = onDrag 42 | self.primary = primary() 43 | self.splitter = splitter() 44 | self.secondary = secondary() 45 | } 46 | 47 | //MARK: Modifiers 48 | 49 | // Note: Modifiers return a new HSplit instance with the same state except for what is 50 | // being modified. 51 | 52 | /// Return a new HSplit with the `splitter` set to the `splitter` passed-in. 53 | public func splitter(@ViewBuilder _ splitter: @escaping ()->T) -> HSplit where T: View { 54 | return HSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: splitter, secondary: { secondary }) 55 | } 56 | 57 | /// Return a new instance of HSplit with `constraints` set to these values. 58 | public func constraints(minPFraction: CGFloat? = nil, minSFraction: CGFloat? = nil, priority: SplitSide? = nil, dragToHideP: Bool = false, dragToHideS: Bool = false) -> HSplit { 59 | let constraints = SplitConstraints(minPFraction: minPFraction, minSFraction: minSFraction, priority: priority, dragToHideP: dragToHideP, dragToHideS: dragToHideS) 60 | return HSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 61 | } 62 | 63 | /// Return a new instance of HSplit with `onDrag` set to `callback`. 64 | /// 65 | /// The `callback` will be executed as `splitter` is dragged, with the current value of `privateFraction`. 66 | /// Note that `fraction` is different. It is only set when drag ends, and it is used to determine the initial fraction at open. 67 | public func onDrag(_ callback: ((CGFloat)->Void)?) -> HSplit { 68 | return HSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: callback, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 69 | } 70 | 71 | /// Return a new instance of HSplit with its `splitter.styling` set to these values. 72 | public func styling(color: Color? = nil, inset: CGFloat? = nil, visibleThickness: CGFloat? = nil, invisibleThickness: CGFloat? = nil, hideSplitter: Bool = false) -> HSplit { 73 | let styling = SplitStyling(color: color, inset: inset, visibleThickness: visibleThickness, invisibleThickness: invisibleThickness, hideSplitter: hideSplitter) 74 | splitter.styling.reset(from: styling) 75 | return HSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 76 | } 77 | 78 | /// Return a new instance of HSplit with `fraction` set to this FractionHolder 79 | public func fraction(_ fraction: FractionHolder) -> HSplit { 80 | HSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 81 | } 82 | 83 | /// Return a new instance of HSplit with `fraction` set to a FractionHolder holding onto this CGFloat 84 | public func fraction(_ fraction: CGFloat) -> HSplit { 85 | self.fraction(FractionHolder(fraction)) 86 | } 87 | 88 | /// Return a new instance of HSplit with `hide` set to this SideHolder 89 | public func hide(_ side: SideHolder) -> HSplit { 90 | HSplit(fraction: fraction, hide: side, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 91 | } 92 | 93 | /// Return a new instance of HSplit with `hide` set to a SideHolder holding onto this SplitSide 94 | public func hide(_ side: SplitSide) -> HSplit { 95 | self.hide(SideHolder(side)) 96 | } 97 | 98 | } 99 | 100 | struct HSplit_Previews: PreviewProvider { 101 | static var previews: some View { 102 | HSplit( 103 | left: { Color.green }, 104 | right: { 105 | VSplit( 106 | top: { Color.red }, 107 | bottom: { 108 | HSplit( 109 | left: { Color.blue }, 110 | right: { Color.yellow } 111 | ) 112 | } 113 | ) 114 | } 115 | ) 116 | 117 | HSplit( 118 | left: { 119 | VSplit(top: { Color.red }, bottom: { Color.green }) 120 | }, 121 | right: { 122 | VSplit(top: { Color.yellow }, bottom: { Color.blue }) 123 | } 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/SplitView/Split.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitViews.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 8/9/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A View with a draggable `splitter` between `primary` and `secondary`. 11 | /// 12 | /// Views are layed out either horizontally or vertically as defined by `layout` 13 | /// and separated by `spacing()`, and the `splitter` is centered within it. 14 | /// 15 | /// The same Split view is used regardless of `layout`, since the math is all the same but applied 16 | /// to width or height depending on whether `layout.isHorizontal` or not. 17 | public struct Split: View { 18 | 19 | /// The `primary` View, left when `layout==.horizontal`, top when `layout==.vertical`. 20 | private let primary: P 21 | /// The `secondary` View, right when `layout==.horizontal`, bottom when `layout==.vertical`. 22 | private let secondary: S 23 | /// The `splitter` View that sits between `primary` and `secondary`. 24 | /// When set up using ViewModifiers, by default either `Splitter.horizontal` or `Splitter.vertical`. 25 | private let splitter: D 26 | /// The constraints within which the splitter can travel and which side if any has priority 27 | private let constraints: SplitConstraints 28 | /// Function to execute with `constrainedFraction` as argument during drag 29 | private let onDrag: ((CGFloat)->Void)? 30 | /// The minimum fraction of full width/height that `primary` can occupy 31 | private let minPFraction: CGFloat? 32 | /// The minimum fraction of full width/height that `secondary` can occupy 33 | private let minSFraction: CGFloat? 34 | /// Whether `primary` can be hidden by dragging beyond half of `minPFraction` 35 | private let dragToHideP: Bool 36 | /// Whether `secondary` can be hidden by dragging beyond half of `minSFraction` 37 | private let dragToHideS: Bool 38 | /// Used to change the SplitLayout of a Split 39 | @ObservedObject private var layout: LayoutHolder 40 | /// Only affects the initial layout, but updated to `constrainedFraction` after dragging ends. 41 | /// In this way, Split users can save the `FractionHolder` state to reflect slider position for restarts. 42 | @ObservedObject private var fraction: FractionHolder 43 | /// Use to hide/show `secondary` independent of dragging. When value is `false`, will restore to `constrainedFraction`. 44 | @ObservedObject private var hide: SideHolder 45 | /// Fraction that tracks the `splitter` position across full width/height, where minPFraction <= constrainedFraction <= (1-minSFraction) 46 | @State private var constrainedFraction: CGFloat 47 | /// Fraction that tracks the cursor across full width/height during drag, where 0 <= fullFraction <= 1 48 | @State private var fullFraction: CGFloat 49 | /// The previous size, used to determine how to change `constrainedFraction` as size changes 50 | @State private var oldSize: CGSize? 51 | /// The previous position as we drag the `splitter` 52 | @State private var previousPosition: CGFloat? 53 | 54 | public var body: some View { 55 | GeometryReader { geometry in 56 | let horizontal = layout.isHorizontal 57 | let size = geometry.size 58 | let width = size.width 59 | let height = size.height 60 | let length = horizontal ? width : height 61 | let breadth = horizontal ? height : width 62 | let hidePrimary = sideToHide().isPrimary || hide.side.isPrimary 63 | let hideSecondary = sideToHide().isSecondary || hide.side.isSecondary 64 | let minPLength = length * ((hidePrimary ? 0 : minPFraction) ?? 0) 65 | let minSLength = length * ((hideSecondary ? 0 : minSFraction) ?? 0) 66 | let pLength = max(minPLength, pLength(in: size)) 67 | let sLength = max(minSLength, sLength(in: size)) 68 | let spacing = spacing() 69 | let pWidth = horizontal ? max(minPLength, min(width - spacing, pLength - spacing / 2)) : breadth 70 | let pHeight = horizontal ? breadth : max(minPLength, min(height - spacing, pLength - spacing / 2)) 71 | let sWidth = horizontal ? max(minSLength, min(width - pLength, sLength - spacing / 2)) : breadth 72 | let sHeight = horizontal ? breadth : max(minSLength, min(height - pLength, sLength - spacing / 2)) 73 | let sOffset = horizontal ? CGSize(width: pWidth + spacing, height: 0) : CGSize(width: 0, height: pHeight + spacing) 74 | let dCenter = horizontal ? CGPoint(x: pWidth + spacing / 2, y: height / 2) : CGPoint(x: width / 2, y: pHeight + spacing / 2) 75 | ZStack(alignment: .topLeading) { 76 | if !hidePrimary { 77 | primary 78 | .frame(width: pWidth, height: pHeight) 79 | } 80 | if !hideSecondary { 81 | secondary 82 | .frame(width: sWidth, height: sHeight) 83 | .offset(sOffset) 84 | } 85 | // Only show the splitter if it is draggable. See isDraggable comments. 86 | if isDraggable() { 87 | splitter 88 | .position(dCenter) 89 | .simultaneousGesture(drag(in: size)) 90 | } 91 | } 92 | // Our size changes when the window size changes or the containing window's size changes. 93 | // Note our size doesn't change when dragging the splitter, but when we have nested split 94 | // views, dragging our splitter can cause the size of another split view to change. 95 | // Use task instead of both onChange and onAppear because task will only executed when 96 | // geometry.size changes. Sometimes onAppear starts with CGSize.zero, which causes issues. 97 | .task(id: geometry.size) { 98 | setConstrainedFraction(in: geometry.size) 99 | } 100 | .clipped() // Can cause problems in some List styles if not clipped 101 | .environmentObject(layout) 102 | .onChange(of: fraction.value) { new in constrainedFraction = new } 103 | } 104 | } 105 | 106 | /// Public init only allows `primary` and `secondary`, with `splitter` defaulting to Splitter. 107 | /// 108 | /// The `layout`, `fraction`, `hide` , `constraints`, and any custom `splitter` must be specified using the modifiers if they are not defaults 109 | public init(@ViewBuilder primary: @escaping ()->P, @ViewBuilder secondary: @escaping ()->S) where D == Splitter { 110 | let layout = LayoutHolder() 111 | let fraction = FractionHolder() 112 | let hide = SideHolder() 113 | let constraints = SplitConstraints() 114 | self.init(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: nil, primary: { primary() }, splitter: { D() }, secondary: { secondary() }) 115 | } 116 | 117 | /// Private init requires all values for Split state to be specified and is used by the modifiers. 118 | private init(_ layout: LayoutHolder, fraction: FractionHolder, hide: SideHolder, constraints: SplitConstraints, onDrag: ((CGFloat)->Void)?, @ViewBuilder primary: @escaping ()->P, @ViewBuilder splitter: @escaping ()->D, @ViewBuilder secondary: @escaping ()->S) { 119 | self.layout = layout 120 | self.fraction = fraction 121 | self.hide = hide 122 | self.constraints = constraints 123 | self.onDrag = onDrag 124 | self.primary = primary() 125 | self.splitter = splitter() 126 | self.secondary = secondary() 127 | _constrainedFraction = State(initialValue: fraction.value) // Local fraction updated during drag 128 | _fullFraction = State(initialValue: fraction.value) // Local fraction updated during drag 129 | // Constants we use a lot and want to simplify access and avoid recomputing 130 | minPFraction = constraints.minPFraction 131 | minSFraction = constraints.minSFraction 132 | dragToHideP = constraints.minPFraction != nil && constraints.dragToHideP 133 | dragToHideS = constraints.minSFraction != nil && constraints.dragToHideS 134 | } 135 | 136 | /// Return the spacing between `primary` and `secondary`, which is occupied by the splitter's `visibleThickness`. 137 | /// 138 | /// If we are previewing the hide (i.e., drag-to-hide) or we are using the `hideSplitter` styling and a side is hidden, 139 | /// then return 0, because the splitter is not visible. 140 | private func spacing() -> CGFloat { 141 | let styling = splitter.styling 142 | if styling.previewHide { 143 | return styling.hideSplitter ? 0 : styling.visibleThickness 144 | } else if hide.side != nil && styling.hideSplitter { 145 | return 0 146 | } else { 147 | return styling.visibleThickness 148 | } 149 | } 150 | 151 | /// Set the constrainedFraction to maintain the size of the priority side when size changes, as called from task(id:) modifier. 152 | private func setConstrainedFraction(in size: CGSize) { 153 | guard let side = constraints.priority else { return } 154 | guard let oldSize else { 155 | // We need to know the oldSize to be able to adjust constrainedFraction in a way 156 | // that maintains a fixed width/height for the priority side. 157 | oldSize = size 158 | return 159 | } 160 | let horizontal = layout.isHorizontal 161 | let oldLength = horizontal ? oldSize.width : oldSize.height 162 | let newLength = horizontal ? size.width : size.height 163 | let delta = newLength - oldLength 164 | self.oldSize = size // Retain even if delta might be zero because layout might change 165 | if delta == 0 { return } 166 | let oldPLength = constrainedFraction * oldLength 167 | // If holding the primary side constant, the pLength doesn't change 168 | let newPLength = side.isPrimary ? oldPLength : oldPLength + delta 169 | let newFraction = newPLength / newLength 170 | // Always keep the constrainedFraction within bounds of minimums if specified 171 | constrainedFraction = min(1 - (minSFraction ?? 0), max((minPFraction ?? 0), newFraction)) 172 | fraction.value = constrainedFraction 173 | } 174 | 175 | /// The Gesture recognized by the `splitter`. 176 | /// 177 | /// The main function of dragging is to modify the `constrainedFraction` and to track `fullFraction`. 178 | /// 179 | /// Whenever we drag, we also set `hide.value` to `nil`. This is because the `pLength` and 180 | /// `sLength` key off of `hide` to return the full width/height when its value is non-nil. 181 | /// 182 | /// When we are done dragging, we the value of `fraction`, which does nothing unless someone 183 | /// is holding onto it. If, for example, a FractionHolder was passed-in using the `fraction()` modifier 184 | /// here or in HSplit/VSplit, then we keep its value in sync so that next time the Split view is opened, it 185 | /// maintains its state. 186 | /// 187 | /// If `dragToHideP`/`dragToHideS` is set in constraints, automatically hide the side when done dragging if 188 | /// `sideToHide` returns a side that should be hidden. The `splitter` is always hidden in this case - 189 | /// iow, `splitter.styling.hideSplitter` is forced to true in the `init` method. 190 | /// 191 | /// Note that during drag, we "preview" that a side will be hidden. While previewing, the `splitter` 192 | /// color is `.clear`, and the size of `primary` and `secondary` are adjusted. This is different 193 | /// than when a side is actually hidden (i.e., when `hide.side` is non-nil). In the latter case, the 194 | /// `splitter` is not part of `body` - it doesn't exist to be dragged. 195 | private func drag(in size: CGSize) -> some Gesture { 196 | return DragGesture() 197 | .onChanged { gesture in 198 | unhide(in: size) // Unhide if the splitter is hidden, but resetting constrainedFraction first 199 | let fraction = fraction(for: gesture, in: size) 200 | constrainedFraction = fraction.constrained 201 | fullFraction = fraction.full 202 | splitter.styling.previewHide = !isDraggable() || sideToHide() != nil 203 | onDrag?(constrainedFraction) 204 | previousPosition = layout.isHorizontal ? constrainedFraction * size.width : constrainedFraction * size.height 205 | } 206 | .onEnded { gesture in 207 | previousPosition = nil 208 | splitter.styling.previewHide = false // We are never previewing the hidden state when drag ends 209 | hide.side = sideToHide() 210 | // The fullFraction is used to determine the sideToHide, so we need to reset when done dragging, 211 | // but *after* setting the hide.side. 212 | fullFraction = constrainedFraction 213 | fraction.value = constrainedFraction 214 | } 215 | } 216 | 217 | /// Return the side to hide for previewing in the drag-to-hide operation. 218 | /// 219 | /// Note a side is not necessarily hidden when `sideToHide` is called. 220 | /// 221 | /// Use a rounded-to-3-decimal-places constrainedFraction because... floating point. 222 | private func sideToHide() -> SplitSide? { 223 | guard dragToHideP || dragToHideS else { return nil } 224 | if dragToHideP && (round(fullFraction * 1000) / 1000.0) <= (minPFraction! / 2) { 225 | return .primary 226 | } else if dragToHideS && (round((1 - fullFraction) * 1000) / 1000.0) <= (minSFraction! / 2) { 227 | return .secondary 228 | } else { 229 | return nil 230 | } 231 | } 232 | 233 | /// Return a new value for `constrained` and `full` fractions based on the DragGesture. 234 | /// 235 | /// The `constrained` value is always between `minSFraction` and `minPFraction` (if specified). 236 | /// The `full` value is always between 0 and 1. 237 | /// 238 | /// We use a delta based on `previousPosition` so the `splitter` follows the location where drag begins, not 239 | /// the center of the splitter. This way the drag is smooth from the beginning, rather than jumping the splitter location to 240 | /// the starting drag point. 241 | func fraction(for gesture: DragGesture.Value, in size: CGSize) -> (constrained: CGFloat, full: CGFloat) { 242 | let horizontal = layout.isHorizontal 243 | let length = horizontal ? size.width : size.height // Size in direction of dragging 244 | let splitterLocation = length * constrainedFraction // Splitter position prior to drag 245 | let gestureLocation = horizontal ? gesture.location.x : gesture.location.y // Gesture location in direction of dragging 246 | let gestureTranslation = horizontal ? gesture.translation.width : gesture.translation.height // Gesture movement since beginning of drag 247 | let delta = previousPosition == nil ? gestureTranslation : gestureLocation - previousPosition! // Amount moved since last change 248 | let constrainedLocation = max(0, min(length, splitterLocation + delta)) // New location kept in proper bounds 249 | let fullFraction = constrainedLocation / length // Fraction of full size without regard to constraints 250 | let constrainedFraction = min(1 - (minSFraction ?? 0), max((minPFraction ?? 0), fullFraction)) // Fraction of full size kept within constraints 251 | return (constrained: constrainedFraction, full: fullFraction) 252 | } 253 | 254 | /// Return whether the splitter is draggable. 255 | /// 256 | /// The splitter becomes non-draggable if `splitter.styling.hideSplitter` is `true` 257 | /// and either side is hidden. It will also be non-draggable when the `primary` side is 258 | /// hidden and `minPFraction` is specified, or when the `secondary` side is hidden 259 | /// and `minSFraction` is specified. When the splitter is non-draggable, it is not part of 260 | /// the `body` of Split. It is not just hidden -- it doesn't even exist to respond to drag events. 261 | /// 262 | /// When using drag-to-split by specifying `constraints.dragToHideP` and/or 263 | /// `constraints.dragToHideS`, `splitter.styling.hideSplitter` is forced to `true`. 264 | /// Why? It's easier to see than explain. Try removing the last line in the private `init` and 265 | /// see for yourself. 266 | /// 267 | /// **Important**: You must provide a means to unhide the side (e.g., a hide/show 268 | /// button) if your splitter can become non-draggable. 269 | /// 270 | /// On a related note, when you use an invisible splitter, you will typically specify min 271 | /// fractions it has to stay within. If you don't, then it can be dragged to the edge, and 272 | /// your user will have no visual indication that the other side can be re-exposed by dragging 273 | /// the invisible splitter out again. 274 | private func isDraggable() -> Bool { 275 | if hide.side == nil { 276 | return true 277 | } else { 278 | return !splitter.styling.hideSplitter 279 | } 280 | } 281 | 282 | /// Unhide before dragging if a side is hidden. 283 | /// 284 | /// When we set `hide.size` to nil, the `body` is recomputed based on `constrainedFraction`. 285 | /// However, `constrainedFraction` is set to what it was before hiding (so it can be restored properly). 286 | /// Here we reset `constrainedFraction` to the "hidden" position so that drag behaves smoothly from 287 | /// that position. 288 | private func unhide(in size: CGSize) { 289 | if hide.side != nil { 290 | let length = layout.isHorizontal ? size.width : size.height 291 | let pLength = pLength(in: size) 292 | constrainedFraction = pLength/length 293 | hide.side = nil 294 | } 295 | } 296 | 297 | /// The length of `primary` in the `layout` direction, without regard to any inset for the Splitter 298 | private func pLength(in size: CGSize) -> CGFloat { 299 | let length = layout.isHorizontal ? size.width : size.height 300 | if let side = hide.side { 301 | return side.isSecondary ? length : 0 302 | } else { 303 | if let sideToHide = sideToHide() { 304 | return sideToHide.isSecondary ? length : 0 305 | } else { 306 | return length * constrainedFraction 307 | } 308 | } 309 | } 310 | 311 | /// The length of `secondary` in the `layout` direction, without regard to any inset for the Splitter 312 | private func sLength(in size: CGSize) -> CGFloat { 313 | let length = layout.isHorizontal ? size.width : size.height 314 | if let side = hide.side { 315 | return side.isPrimary ? length : 0 316 | } else { 317 | if let sideToHide = sideToHide() { 318 | return sideToHide.isPrimary ? length : 0 319 | } else { 320 | return length - pLength(in: size) 321 | } 322 | } 323 | } 324 | 325 | //MARK: Modifiers 326 | 327 | /// Return a new Split with the `splitter` set to the `splitter` passed-in. 328 | public func splitter(@ViewBuilder _ splitter: @escaping ()->T) -> Split where T: View { 329 | return Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: splitter, secondary: { secondary }) 330 | } 331 | 332 | /// Return a new instance of Split with `constraints` set to a SplitConstraints holding these values. 333 | public func constraints(minPFraction: CGFloat? = nil, minSFraction: CGFloat? = nil, priority: SplitSide? = nil, dragToHideP: Bool = false, dragToHideS: Bool = false) -> Split { 334 | let constraints = SplitConstraints(minPFraction: minPFraction, minSFraction: minSFraction, priority: priority, dragToHideP: dragToHideP, dragToHideS: dragToHideS) 335 | return Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 336 | } 337 | 338 | /// Return a new instance of Split with `constraints` set to this SplitConstraints. 339 | /// 340 | /// This is a convenience method for HSplit and VSplit. 341 | public func constraints(_ constraints: SplitConstraints) -> Split { 342 | self.constraints(minPFraction: constraints.minPFraction, minSFraction: constraints.minSFraction, priority: constraints.priority, dragToHideP: constraints.dragToHideP, dragToHideS: constraints.dragToHideS) 343 | } 344 | 345 | /// Return a new instance of Split with `onDrag` set to `callback`. 346 | /// 347 | /// The `callback` will be executed as `splitter` is dragged, with the current value of `constrainedFraction`. 348 | /// Note that `fraction` is different. It is only set when drag ends, and it is used to determine the initial fraction at open. 349 | /// 350 | /// This is a convenience method for HSplit and VSplit. 351 | public func onDrag(_ callback: ((CGFloat)->Void)?) -> Split { 352 | return Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: callback, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 353 | } 354 | 355 | /// Return a new instance of Split with its `splitter.styling` set to these values. 356 | /// 357 | /// This is a convenience method for `Splitter.line()` which is also used by `Splitter.invisible()`. 358 | public func styling(color: Color? = nil, inset: CGFloat? = nil, visibleThickness: CGFloat? = nil, invisibleThickness: CGFloat? = nil, hideSplitter: Bool = false) -> Split { 359 | let styling = SplitStyling(color: color, inset: inset, visibleThickness: visibleThickness, invisibleThickness: invisibleThickness, hideSplitter: hideSplitter) 360 | splitter.styling.reset(from: styling) 361 | return Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 362 | } 363 | 364 | /// Return a new instance of Split with its `splitter.styling` set to the values of this `styling`. 365 | /// 366 | /// This is a convenience method for HSplit and VSplit. 367 | public func styling(_ styling: SplitStyling) -> Split { 368 | self.styling(color: styling.color, inset: styling.inset, visibleThickness: styling.visibleThickness, invisibleThickness: styling.invisibleThickness, hideSplitter: styling.hideSplitter) 369 | } 370 | 371 | /// Return a new instance of Split with `layout` set to this LayoutHolder. 372 | /// 373 | /// Split only supports `layout` specified using a LayoutHolder because if you are not going 374 | /// to change the `layout`, then you should just use HSplit or VSplit. 375 | public func layout(_ layout: LayoutHolder) -> Split { 376 | Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 377 | } 378 | 379 | /// Return a new instance of Split with `fraction` set to this FractionHolder. 380 | public func fraction(_ fraction: FractionHolder) -> Split { 381 | Split(layout, fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 382 | } 383 | 384 | /// Return a new instance of Split with `fraction` set to a FractionHolder holding onto this CGFloat. 385 | public func fraction(_ fraction: CGFloat) -> Split { 386 | self.fraction(FractionHolder(fraction)) 387 | } 388 | 389 | /// Return a new instance of Split with `hide` set to this SideHolder. 390 | public func hide(_ side: SideHolder) -> Split { 391 | Split(layout, fraction: fraction, hide: side, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 392 | } 393 | 394 | /// Return a new instance of Split with `hide` set to a SideHolder holding onto this SplitSide. 395 | public func hide(_ side: SplitSide) -> Split { 396 | self.hide(SideHolder(side)) 397 | } 398 | 399 | } 400 | 401 | struct Split_Previews: PreviewProvider { 402 | static var previews: some View { 403 | Split( 404 | primary: { Color.green }, 405 | secondary: { 406 | Split( 407 | primary: { Color.red }, 408 | secondary: { 409 | Split( 410 | primary: { Color.blue }, 411 | secondary: { Color.yellow } 412 | ) 413 | .layout(LayoutHolder(.horizontal)) 414 | } 415 | ) 416 | .layout(LayoutHolder(.vertical)) 417 | } 418 | ) 419 | .layout(LayoutHolder(.horizontal)) 420 | 421 | Split( 422 | primary: { 423 | Split(primary: { Color.red }, secondary: { Color.green }) 424 | .layout(LayoutHolder(.vertical)) 425 | }, 426 | secondary: { 427 | Split(primary: { Color.yellow }, secondary: { Color.blue }) 428 | .layout(LayoutHolder(.vertical)) 429 | } 430 | ) 431 | .layout(LayoutHolder(.horizontal)) 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /Sources/SplitView/SplitConstraints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitConstraints.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 2/13/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct SplitConstraints { 11 | /// The minimum fraction that the primary view will be constrained within. A value of `nil` means unconstrained. 12 | var minPFraction: CGFloat? 13 | /// The minimum fraction that the secondary view will be constrained within. A value of `nil` means unconstrained. 14 | var minSFraction: CGFloat? 15 | /// The side that should have sizing priority (i.e., stay fixed) as the containing view is resized. A value of `nil` means the fraction remains unchanged. 16 | var priority: SplitSide? 17 | /// Whether to hide the primary side when dragging stops past minPFraction 18 | var dragToHideP: Bool 19 | /// Whether to hide the secondary side when dragging stops past minSFraction 20 | var dragToHideS: Bool 21 | 22 | public init(minPFraction: CGFloat? = nil, minSFraction: CGFloat? = nil, priority: SplitSide? = nil, dragToHideP: Bool = false, dragToHideS: Bool = false) { 23 | self.minPFraction = minPFraction 24 | self.minSFraction = minSFraction 25 | self.priority = priority 26 | // Note: minPFraction/minSFraction must be specified if dragToHideP/dragToHideS is true, 27 | // else dragToHideP/dragToHideS are ignored. 28 | self.dragToHideP = dragToHideP 29 | self.dragToHideS = dragToHideS 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SplitView/SplitEnums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitEnums.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 1/31/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The orientation of the `primary` and `secondary` views (e.g., Vertical = VStack, Horizontal = HStack) 11 | public enum SplitLayout: String, CaseIterable { 12 | case horizontal 13 | case vertical 14 | } 15 | 16 | /// The two sides of a SplitView. 17 | /// 18 | /// Use `isPrimary` and `isSecondary` rather than accessing the cases directly. 19 | /// 20 | /// For `SplitLayout.horizontal`, `primary` is left, `secondary` is right. 21 | /// For `SplitLayout.vertical`, `primary` is top, `secondary` is bottom. 22 | /// 23 | /// For convenience and clarity when creating and constraining an HSplit view, you can use 24 | /// `left` and `right` instead of `primary` and `secondary`. Similarly you can 25 | /// use `top` and `bottom` when creating and constraining a VSplit view. 26 | public enum SplitSide: String { 27 | case primary 28 | case secondary 29 | case left 30 | case right 31 | case top 32 | case bottom 33 | 34 | public var isPrimary: Bool { self == .primary || self == .left || self == .top } 35 | public var isSecondary: Bool { self == .secondary || self == .right || self == .bottom } 36 | } 37 | 38 | /// A SplitSide is generally optional. If so, then if nil, it is neither primary nor secondary. 39 | extension Optional where Wrapped == SplitSide { 40 | public var isPrimary: Bool { self == nil ? false : self!.isPrimary } 41 | public var isSecondary: Bool { self == nil ? false : self!.isSecondary } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SplitView/SplitHolders.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitHolders.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 2/8/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An ObservableObject that `Split` view observes to change what its `layout` is. 11 | /// 12 | /// Use the static `usingUserDefaults` method to save state automatically in `UserDefaults.standard`. 13 | public class LayoutHolder: ObservableObject { 14 | @Published public var value: SplitLayout { 15 | didSet { 16 | setter?(value) 17 | } 18 | } 19 | public var getter: (()->SplitLayout)? 20 | public var setter: ((SplitLayout)->Void)? 21 | 22 | public var isHorizontal: Bool { value == .horizontal } 23 | 24 | public init(_ layout: SplitLayout? = nil, getter: (()->SplitLayout)? = nil, setter: ((SplitLayout)->Void)? = nil) { 25 | value = getter?() ?? layout ?? .horizontal 26 | self.getter = getter 27 | self.setter = setter 28 | } 29 | 30 | public static func usingUserDefaults(_ layout: SplitLayout? = nil, key: String) -> LayoutHolder { 31 | LayoutHolder( 32 | layout, 33 | getter: { 34 | guard 35 | let value = UserDefaults.standard.value(forKey: key) as? String, 36 | let layout = SplitLayout(rawValue: value) 37 | else { 38 | return .horizontal 39 | } 40 | return layout 41 | }, 42 | setter: { layout in 43 | UserDefaults.standard.set(layout.rawValue, forKey: key) 44 | } 45 | ) 46 | } 47 | 48 | public func toggle() { 49 | value = value == .horizontal ? .vertical : .horizontal 50 | } 51 | 52 | } 53 | 54 | /// An ObservableObject that `Split` view observes to change what fraction of the width/height the `splitter` 55 | /// will be positioned at upon open. 56 | /// 57 | /// Use the static `usingUserDefaults` method to save state automatically in `UserDefaults.standard`. 58 | public class FractionHolder: ObservableObject { 59 | @Published public var value: CGFloat { 60 | didSet { 61 | setter?(value) 62 | } 63 | } 64 | public var getter: (()->CGFloat)? 65 | public var setter: ((CGFloat)->Void)? 66 | 67 | public init(_ fraction: CGFloat? = nil, getter: (()->CGFloat)? = nil, setter: ((CGFloat)->Void)? = nil) { 68 | value = getter?() ?? fraction ?? 0.5 69 | self.getter = getter 70 | self.setter = setter 71 | } 72 | 73 | public static func usingUserDefaults(_ fraction: CGFloat? = nil, key: String) -> FractionHolder { 74 | FractionHolder( 75 | fraction, 76 | getter: { UserDefaults.standard.value(forKey: key) as? CGFloat ?? fraction ?? 0.5 }, 77 | setter: { fraction in UserDefaults.standard.set(fraction, forKey: key) } 78 | ) 79 | } 80 | } 81 | 82 | /// An ObservableObject that `Split` view observes to change whether one of the `SplitSide`s is hidden. 83 | /// 84 | /// Use the static `usingUserDefaults` method to save state automatically in `UserDefaults.standard`. 85 | public class SideHolder: ObservableObject { 86 | @Published private var value: SplitSide? { 87 | didSet { 88 | setter?(value) 89 | } 90 | } 91 | public var getter: (()->SplitSide?)? 92 | public var setter: ((SplitSide?)->Void)? 93 | public var side: SplitSide? { 94 | get { value } 95 | set { setValue(newValue) } 96 | } 97 | public var oldSide: SplitSide? { oldValue } 98 | private var oldValue: SplitSide? 99 | 100 | public init(_ hide: SplitSide? = nil, getter: (()->SplitSide?)? = nil, setter: ((SplitSide?)->Void)? = nil) { 101 | let value = getter?() ?? hide 102 | self.value = value 103 | self.getter = getter 104 | self.setter = setter 105 | // Note .secondary will always toggle() by default if hide is initially nil. 106 | // If you want to toggle .primary when hide is initially nil, then use toggle(.primary) 107 | // or toggle(.left) or toggle(.top). See discussion below in the toggle method. 108 | oldValue = value == nil ? .secondary : nil 109 | } 110 | 111 | /// Hide the `side`. 112 | public func hide(_ side: SplitSide) { 113 | setValue(side) 114 | } 115 | 116 | /// Toggle whether `side` is hidden or not. 117 | /// 118 | /// For example, multiple invocations of `toggle(.primary)` will alternate between the 119 | /// `.primary` (or `.left` or `.top`) side being hidden or visible. 120 | /// 121 | /// If `side` is not specified, then `toggle` does hide/show of the` .secondary` side or of the 122 | /// initially hidden `side` that was identified when the SideHolder was instantiated. Thus, you can use a 123 | /// SideHolder instantiated with `SideHolder()` and use `toggle()` to hide/show the `.secondary` 124 | /// side. If you want to hide/show the `.primary` side that will be initially visible, then you can use 125 | /// `SideHolder()` but hide/show using `toggle(.primary)`. 126 | public func toggle(_ side: SplitSide? = nil) { 127 | guard let side else { 128 | setValue(oldValue) 129 | return 130 | } 131 | if (side.isPrimary && value.isPrimary) || (side.isSecondary && value.isSecondary) { 132 | setValue(oldValue) 133 | } else { 134 | setValue(side) 135 | } 136 | } 137 | 138 | private func setValue(_ side: SplitSide?) { 139 | guard value != side else { return } 140 | let oldSide = value 141 | value = side 142 | oldValue = oldSide 143 | } 144 | 145 | public static func usingUserDefaults(_ hide: SplitSide? = nil, key: String) -> SideHolder { 146 | SideHolder( 147 | hide, 148 | getter: { 149 | guard 150 | let value = UserDefaults.standard.value(forKey: key) as? String, 151 | let side = SplitSide(rawValue: value) 152 | else { 153 | return nil 154 | } 155 | return side 156 | }, 157 | setter: { side in 158 | UserDefaults.standard.set(side?.rawValue, forKey: key) 159 | } 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/SplitView/SplitModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitModifiers.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 2/2/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A ViewModifier to split Content with `secondary` in the SplitLayout direction held by `layout` direction. 11 | /// 12 | /// The views are separated by a draggable Splitter. 13 | /// 14 | /// Other customization after using this modifier (e.g., `fraction`, `hide`, `styling`, `constraints`, `splitter`) are done using 15 | /// those separate modifiers on the Split instance returned from this modifier. 16 | public struct SplitModifier: ViewModifier { 17 | let layout: LayoutHolder 18 | let secondary: ()->S 19 | 20 | public func body(content: Content) -> some View { 21 | Split(primary: {content}, secondary: secondary) 22 | .layout(layout) 23 | } 24 | 25 | public init(_ layout: LayoutHolder, @ViewBuilder secondary: @escaping (()->S)) { 26 | self.layout = layout 27 | self.secondary = secondary 28 | } 29 | } 30 | 31 | /// A ViewModifier to split Content horizontally with `secondary`. 32 | /// 33 | /// The views are separated by a draggable Splitter. 34 | /// 35 | /// Other customization after using this modifier (e.g., `fraction`, `hide`, `styling`, `constraints`, `splitter`) are done using 36 | /// those separate modifiers on the HSplit instance returned from this modifier. 37 | public struct HSplitModifier: ViewModifier { 38 | let secondary: ()->S 39 | 40 | public func body(content: Content) -> some View { 41 | HSplit(left: {content}, right: secondary) 42 | } 43 | 44 | public init(@ViewBuilder secondary: @escaping (()->S)) { 45 | self.secondary = secondary 46 | } 47 | } 48 | 49 | /// A ViewModifier to split Content vertically with `secondary`. 50 | /// 51 | /// The views are separated by a draggable Splitter. 52 | /// 53 | /// Other customization after using this modifier (e.g., `fraction`, `hide`, `styling`, `constraints`, `splitter`) are done using 54 | /// those separate modifiers on the VSplit instance returned from this modifier. 55 | public struct VSplitModifier: ViewModifier { 56 | let secondary: ()->S 57 | 58 | public func body(content: Content) -> some View { 59 | VSplit(top: {content}, bottom: secondary) 60 | } 61 | 62 | public init(@ViewBuilder secondary: @escaping (()->S)) { 63 | self.secondary = secondary 64 | } 65 | } 66 | 67 | extension View { 68 | 69 | /// Return an instance of Split in the SplitLayout direction held by `layout`, with a Splitter separating this `primary` View and `secondary`. 70 | public func split(_ layout: LayoutHolder, @ViewBuilder secondary: @escaping (()->some View)) -> some View { 71 | modifier(SplitModifier(layout, secondary: secondary)) 72 | } 73 | 74 | /// Return an instance of Split in `layout` direction, with a Splitter separating this `primary` View and `secondary`. 75 | public func split(_ layout: SplitLayout, @ViewBuilder secondary: @escaping (()->some View)) -> some View { 76 | split(LayoutHolder(layout), secondary: secondary) 77 | } 78 | 79 | /// Return an instance of Split in `.horizontal` direction, with a Splitter separating this `primary` View and `secondary`. 80 | public func split(@ViewBuilder secondary: @escaping (()->some View)) -> some View { 81 | split(LayoutHolder(.horizontal), secondary: secondary) 82 | } 83 | 84 | /// Return an instance of HSplit with a Splitter separating this `primary` View and `secondary`. 85 | public func hSplit(@ViewBuilder secondary: @escaping (()->some View)) -> some View { 86 | modifier(HSplitModifier(secondary: secondary)) 87 | } 88 | 89 | /// Return an instance of VSplit with a Splitter separating this `primary` View and `secondary`. 90 | public func vSplit(@ViewBuilder secondary: @escaping (()->some View)) -> some View { 91 | modifier(VSplitModifier(secondary: secondary)) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/SplitView/SplitStyling.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitStyling.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 3/6/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | public class SplitStyling: ObservableObject { 12 | /// Color of the visible part of the default Splitter. 13 | public var color: Color 14 | /// The inset for the visible part of the default Splitter from the ends it reaches to. 15 | public var inset: CGFloat 16 | /// The visible thickness of the default Splitter and the `spacing` between the `primary` and `secondary` views. 17 | public var visibleThickness: CGFloat 18 | /// The thickness across which the dragging will be detected. 19 | public var invisibleThickness: CGFloat 20 | /// Whether to hide the splitter along with the side when SplitSide is set. 21 | public var hideSplitter: Bool 22 | /// Whether we are previewing what hiding will look like. 23 | @Published public var previewHide: Bool 24 | 25 | public init(color: Color? = nil, inset: CGFloat? = nil, visibleThickness: CGFloat? = nil, invisibleThickness: CGFloat? = nil, hideSplitter: Bool = false) { 26 | self.color = color ?? Splitter.defaultColor 27 | self.inset = inset ?? Splitter.defaultInset 28 | self.visibleThickness = visibleThickness ?? Splitter.defaultVisibleThickness 29 | self.invisibleThickness = invisibleThickness ?? Splitter.defaultInvisibleThickness 30 | self.hideSplitter = hideSplitter 31 | self.previewHide = false // We never start out previewing 32 | } 33 | 34 | /// As an ObservableObject, when we want to change to a different SplitStyling, we need to just modify the properties of this instance. 35 | public func reset(from styling: SplitStyling) { 36 | color = styling.color 37 | inset = styling.inset 38 | visibleThickness = styling.visibleThickness 39 | invisibleThickness = styling.invisibleThickness 40 | hideSplitter = styling.hideSplitter 41 | previewHide = styling.previewHide 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SplitView/Splitter+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Splitter+Extensions.swift 3 | // SplitView 4 | // 5 | // This file is a place to hold generally useful extensions of Splitter. If you create one, 6 | // please add your name below to identify your extension, add it to this file, and submit 7 | // a pull request. Note that your custom Splitter should probably conform to SplitDivider. 8 | // Your custom splitter can get its layout from the LayoutHolder in the Environment or 9 | // directly as part of its initialization. 10 | // 11 | // Created by Steven Harris on 2/16/23. 12 | // 13 | // Extension authors: 14 | // 15 | // Steven G. Harris created `line` and `invisible` extensions. 16 | // 17 | 18 | import SwiftUI 19 | 20 | extension Splitter { 21 | 22 | /// A Splitter (that responds to changes in layout) that is a line across the full breadth of the view, by default gray and visibleThickness of 1 23 | public static func line(color: Color? = nil, visibleThickness: CGFloat? = nil) -> Splitter { 24 | return Splitter(color: color, inset: 0, visibleThickness: visibleThickness ?? 1) 25 | } 26 | 27 | /// An invisible Splitter (that responds to changes in layout) that is a line across the full breadth of the view 28 | public static func invisible() -> Splitter { 29 | Splitter.line(visibleThickness: 0) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SplitView/Splitter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Splitter.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 8/18/21. 6 | // 7 | 8 | /// Custom splitters must conform to SplitDivider, just like the default `Splitter`. 9 | @MainActor 10 | public protocol SplitDivider: View { 11 | var styling: SplitStyling { get } 12 | } 13 | 14 | import SwiftUI 15 | 16 | /// The Splitter that separates the `primary` from `secondary` views in a `Split` view. 17 | /// 18 | /// The Splitter holds onto `styling`, which is accessed by Split to determine the `visibleThickness` by which 19 | /// the `primary` and `secondary` views are separated. The `styling` also publishes `previewHide`, which 20 | /// specifies whether we are previewing what Split will look like when we hide a side. The Splitter uses `previewHide` 21 | /// to change its `dividerColor` to `.clear` when being previewed, while Split uses it to determine whether the 22 | /// spacing between views should be `visibleThickness` or zero. 23 | @MainActor 24 | public struct Splitter: SplitDivider { 25 | 26 | @EnvironmentObject private var layout: LayoutHolder 27 | @ObservedObject public var styling: SplitStyling 28 | @State private var dividerColor: Color // Changes based on styling.previewHide 29 | private var color: Color { privateColor ?? styling.color } 30 | private var inset: CGFloat { privateInset ?? styling.inset } 31 | private var visibleThickness: CGFloat { privateVisibleThickness ?? styling.visibleThickness } 32 | private var invisibleThickness: CGFloat { privateInvisibleThickness ?? styling.invisibleThickness } 33 | private let privateColor: Color? 34 | private let privateInset: CGFloat? 35 | private let privateVisibleThickness: CGFloat? 36 | private let privateInvisibleThickness: CGFloat? 37 | 38 | // Defaults 39 | public static var defaultColor: Color = Color.gray 40 | public static var defaultInset: CGFloat = 6 41 | public static var defaultVisibleThickness: CGFloat = 4 42 | public static var defaultInvisibleThickness: CGFloat = 30 43 | 44 | public var body: some View { 45 | ZStack { 46 | switch layout.value { 47 | case .horizontal: 48 | Color.clear 49 | .frame(width: invisibleThickness) 50 | .padding(0) 51 | RoundedRectangle(cornerRadius: visibleThickness / 2) 52 | .fill(dividerColor) 53 | .frame(width: visibleThickness) 54 | .padding(EdgeInsets(top: inset, leading: 0, bottom: inset, trailing: 0)) 55 | case .vertical: 56 | Color.clear 57 | .frame(height: invisibleThickness) 58 | .padding(0) 59 | RoundedRectangle(cornerRadius: visibleThickness / 2) 60 | .fill(dividerColor) 61 | .frame(height: visibleThickness) 62 | .padding(EdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset)) 63 | } 64 | } 65 | .contentShape(Rectangle()) 66 | .task { dividerColor = color } // Otherwise, styling.color does not appear at open 67 | // If we are previewing hiding a side using drag-to-hide, and the splitter will be 68 | // hidden when the side is hidden (styling.hideSplitter is true), then set the 69 | // splitter color to clear. When the splitter is actually hidden, it doesn't even 70 | // exist, but when previewing it does, so we have to make it invisible this way. 71 | .onChange(of: styling.previewHide) { hide in 72 | if hide { 73 | dividerColor = styling.hideSplitter ? .clear : privateColor ?? color 74 | } else { 75 | dividerColor = privateColor ?? color 76 | } 77 | } 78 | // Perhaps should consider some kind of custom hoverEffect, since the cursor change 79 | // on hover doesn't work on iOS. 80 | .onHover { inside in 81 | #if targetEnvironment(macCatalyst) || os(macOS) 82 | // With nested split views, it's possible to transition from one Splitter to another, 83 | // so we always need to pop the current cursor (a no-op when it's the only one). We 84 | // may or may not push the hover cursor depending on whether it's inside or not. 85 | NSCursor.pop() 86 | if inside { 87 | layout.isHorizontal ? NSCursor.resizeLeftRight.push() : NSCursor.resizeUpDown.push() 88 | } 89 | #endif 90 | } 91 | } 92 | 93 | public init(color: Color? = nil, inset: CGFloat? = nil, visibleThickness: CGFloat? = nil, invisibleThickness: CGFloat? = nil) { 94 | privateColor = color 95 | privateInset = inset 96 | privateVisibleThickness = visibleThickness 97 | privateInvisibleThickness = invisibleThickness 98 | styling = SplitStyling(color: color, inset: inset, visibleThickness: visibleThickness, invisibleThickness: invisibleThickness) 99 | _dividerColor = State(initialValue: color ?? Self.defaultColor) 100 | } 101 | 102 | public init(styling: SplitStyling) { 103 | privateColor = styling.color 104 | privateInset = styling.inset 105 | privateVisibleThickness = styling.visibleThickness 106 | privateInvisibleThickness = styling.invisibleThickness 107 | self.styling = styling 108 | _dividerColor = State(initialValue: styling.color) 109 | } 110 | 111 | } 112 | 113 | struct Splitter_Previews: PreviewProvider { 114 | static var previews: some View { 115 | Splitter() 116 | .environmentObject(LayoutHolder(.horizontal)) 117 | Splitter(color: Color.red, inset: 2, visibleThickness: 8, invisibleThickness: 30) 118 | .environmentObject(LayoutHolder(.horizontal)) 119 | Splitter.line() 120 | .environmentObject(LayoutHolder(.horizontal)) 121 | Splitter() 122 | .environmentObject(LayoutHolder(.vertical)) 123 | Splitter(color: Color.red, inset: 2, visibleThickness: 8, invisibleThickness: 30) 124 | .environmentObject(LayoutHolder(.vertical)) 125 | Splitter.line() 126 | .environmentObject(LayoutHolder(.vertical)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/SplitView/VSplit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VSplit.swift 3 | // SplitView 4 | // 5 | // Created by Steven Harris on 3/3/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | public struct VSplit: View { 12 | private let fraction: FractionHolder 13 | private let hide: SideHolder 14 | private let constraints: SplitConstraints 15 | private let onDrag: ((CGFloat)->Void)? 16 | private let primary: P 17 | private let splitter: D 18 | private let secondary: S 19 | 20 | public var body: some View { 21 | Split(primary: { primary }, secondary: { secondary }) 22 | .layout(LayoutHolder(.vertical)) 23 | .constraints(constraints) 24 | .onDrag(onDrag) 25 | .splitter { splitter } 26 | .fraction(fraction) 27 | .hide(hide) 28 | } 29 | 30 | public init(@ViewBuilder top: @escaping ()->P, @ViewBuilder bottom: @escaping ()->S) where D == Splitter { 31 | let fraction = FractionHolder() 32 | let hide = SideHolder() 33 | let constraints = SplitConstraints() 34 | self.init(fraction: fraction, hide: hide, constraints: constraints, onDrag: nil, primary: { top() }, splitter: { D() }, secondary: { bottom() }) 35 | } 36 | 37 | private init(fraction: FractionHolder, hide: SideHolder, constraints: SplitConstraints, onDrag: ((CGFloat)->Void)?, @ViewBuilder primary: @escaping ()->P, @ViewBuilder splitter: @escaping ()->D, @ViewBuilder secondary: @escaping ()->S) { 38 | self.fraction = fraction 39 | self.hide = hide 40 | self.constraints = constraints 41 | self.onDrag = onDrag 42 | self.primary = primary() 43 | self.splitter = splitter() 44 | self.secondary = secondary() 45 | } 46 | 47 | //MARK: Modifiers 48 | 49 | // Note: Modifiers return a new VSplit instance with the same state except for what is 50 | // being modified. 51 | 52 | /// Return a new VSplit with the `splitter` set to the `splitter` passed-in. 53 | public func splitter(@ViewBuilder _ splitter: @escaping ()->T) -> VSplit where T: View { 54 | return VSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: splitter, secondary: { secondary }) 55 | } 56 | 57 | /// Return a new instance of VSplit with `constraints` set to these values. 58 | public func constraints(minPFraction: CGFloat? = nil, minSFraction: CGFloat? = nil, priority: SplitSide? = nil, dragToHideP: Bool = false, dragToHideS: Bool = false) -> VSplit { 59 | let constraints = SplitConstraints(minPFraction: minPFraction, minSFraction: minSFraction, priority: priority, dragToHideP: dragToHideP, dragToHideS: dragToHideS) 60 | return VSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 61 | } 62 | 63 | /// Return a new instance of VSplit with `onDrag` set to `callback`. 64 | /// 65 | /// The `callback` will be executed as `splitter` is dragged, with the current value of `privateFraction`. 66 | /// Note that `fraction` is different. It is only set when drag ends, and it is used to determine the initial fraction at open. 67 | public func onDrag(_ callback: ((CGFloat)->Void)?) -> VSplit { 68 | return VSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: callback, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 69 | } 70 | 71 | /// Return a new instance of VSplit with its `splitter.styling` set to these values. 72 | public func styling(color: Color? = nil, inset: CGFloat? = nil, visibleThickness: CGFloat? = nil, invisibleThickness: CGFloat? = nil, hideSplitter: Bool = false) -> VSplit { 73 | let styling = SplitStyling(color: color, inset: inset, visibleThickness: visibleThickness, invisibleThickness: invisibleThickness, hideSplitter: hideSplitter) 74 | splitter.styling.reset(from: styling) 75 | return VSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 76 | } 77 | 78 | /// Return a new instance of VSplit with `fraction` set to this FractionHolder 79 | public func fraction(_ fraction: FractionHolder) -> VSplit { 80 | VSplit(fraction: fraction, hide: hide, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 81 | } 82 | 83 | /// Return a new instance of VSplit with `fraction` set to a FractionHolder holding onto this CGFloat 84 | public func fraction(_ fraction: CGFloat) -> VSplit { 85 | self.fraction(FractionHolder(fraction)) 86 | } 87 | 88 | /// Return a new instance of VSplit with `hide` set to this SideHolder 89 | public func hide(_ side: SideHolder) -> VSplit { 90 | VSplit(fraction: fraction, hide: side, constraints: constraints, onDrag: onDrag, primary: { primary }, splitter: { splitter }, secondary: { secondary }) 91 | } 92 | 93 | /// Return a new instance of VSplit with `hide` set to a SideHolder holding onto this SplitSide 94 | public func hide(_ side: SplitSide) -> VSplit { 95 | self.hide(SideHolder(side)) 96 | } 97 | 98 | } 99 | 100 | struct VSplit_Previews: PreviewProvider { 101 | static var previews: some View { 102 | VSplit( 103 | top: { Color.green }, 104 | bottom: { 105 | HSplit( 106 | left: { Color.red }, 107 | right: { 108 | VSplit( 109 | top: { Color.blue }, 110 | bottom: { Color.yellow } 111 | ) 112 | } 113 | ) 114 | } 115 | ) 116 | 117 | VSplit( 118 | top: { 119 | HSplit(left: { Color.red }, right: { Color.green }) 120 | }, 121 | bottom: { 122 | HSplit(left: { Color.yellow }, right: { Color.blue }) 123 | } 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/SplitViewTests/SplitViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import SplitView 4 | 5 | struct ContentView: View { 6 | let hide = SideHolder() 7 | var body: some View { 8 | VStack(spacing: 0) { 9 | Button("Toggle Hide") { 10 | withAnimation { 11 | hide.toggle() // Toggle between hiding nothing and hiding secondary 12 | } 13 | } 14 | HSplit(left: { Color.red }, right: { Color.green }) 15 | .hide(hide) 16 | .constraints(minPFraction: 0.2, minSFraction: 0.2) 17 | .splitter { Splitter.invisible() } 18 | } 19 | } 20 | } 21 | 22 | final class SplitViewTests: XCTestCase { 23 | func testSplitViewHideSecondary() { 24 | let view = ContentView() 25 | let hide = view.hide 26 | 27 | // Initial state: Both primary (red) and secondary (green) views are visible. 28 | XCTAssertNil(hide.side) 29 | 30 | // Toggle hide to hide the secondary (green) view. 31 | withAnimation { 32 | hide.toggle() 33 | } 34 | XCTAssertEqual(hide.side, .secondary) 35 | 36 | // Toggle hide again to unhide the secondary (green) view. 37 | withAnimation { 38 | hide.toggle() 39 | } 40 | XCTAssertNil(hide.side) 41 | } 42 | 43 | func testSplitViewHidPrimary() { 44 | let view = ContentView() 45 | let hide = view.hide 46 | 47 | // Initial state: Both primary (red) and secondary (green) views are visible. 48 | XCTAssertNil(hide.side) 49 | 50 | // Set the previous value to .primary (to simulate the initial old value) 51 | hide.side = .primary 52 | XCTAssertEqual(hide.side, .primary) 53 | 54 | // Toggle to unhide the primary side (red view). 55 | hide.toggle() 56 | XCTAssertNil(hide.side) 57 | 58 | // Toggle to hide the primary side again (red view). 59 | hide.toggle() 60 | XCTAssertEqual(hide.side, .primary) 61 | 62 | // Toggle to unhide the primary side again (red view). 63 | hide.toggle() 64 | XCTAssertNil(hide.side) 65 | } 66 | } 67 | 68 | --------------------------------------------------------------------------------