├── .gitignore ├── CHANGELOG.md ├── Examples ├── OutlineViewDraggingExample │ ├── OutlineViewDraggingExample.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── OutlineViewDraggingExample.xcscheme │ └── OutlineViewDraggingExample │ │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── FileItemView.swift │ │ ├── Info.plist │ │ ├── OutlineViewDraggingExample.entitlements │ │ ├── OutlineViewDraggingExample.xcconfig │ │ ├── OutlineViewDraggingExampleApp.swift │ │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── ViewModel.swift ├── OutlineViewExample │ ├── OutlineViewExample.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── OutlineViewExample │ │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── FileItemView.swift │ │ ├── Info.plist │ │ ├── OutlineViewExample.entitlements │ │ ├── OutlineViewExample.xcconfig │ │ ├── OutlineViewExampleApp.swift │ │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json └── Screenshot.png ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources └── OutlineView │ ├── AdjustableSeparatorRowView.swift │ ├── NSEdgeInsets+Equatable.swift │ ├── NSEdgeInsets+Zero.swift │ ├── Notifications.swift │ ├── OutlineView.swift │ ├── OutlineViewController.swift │ ├── OutlineViewDataSource.swift │ ├── OutlineViewDelegate.swift │ ├── OutlineViewDragAndDrop.swift │ ├── OutlineViewItem.swift │ ├── OutlineViewUpdater.swift │ ├── StringMangling.swift │ ├── TreeMap.swift │ └── Visibility.swift └── Tests └── OutlineViewTests ├── OutlineViewDataSourceTests.swift ├── OutlineViewItemTests.swift ├── OutlineViewUpdaterTests.swift └── TreeMapTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 2.0 4 | 5 | - Added support for a closure based children provider (thanks to @RCCoop). 6 | - Added support for drag and drop (thanks to @RCCoop). 7 | 8 | ## Version 1.1 9 | 10 | - Added support for displaying and customizing row separator lines. 11 | - Added support for macOS 10.15 (thanks to @melMass). 12 | 13 | ## Version 1.0.1 14 | 15 | - Updated example project. 16 | 17 | ## Version 1.0 18 | 19 | - Initial release. 20 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 336C600325CCAEE700230C37 /* OutlineView in Frameworks */ = {isa = PBXBuildFile; productRef = 336C600225CCAEE700230C37 /* OutlineView */; }; 11 | 94DB3C032950D3DB00515489 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB3BFD2950D1A700515489 /* ViewModel.swift */; }; 12 | 94DB3C042950D3DF00515489 /* OutlineViewDraggingExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB3C002950D1A700515489 /* OutlineViewDraggingExampleApp.swift */; }; 13 | 94DB3C052950D3E200515489 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB3BFA2950D1A700515489 /* FileItemView.swift */; }; 14 | 94DB3C062950D3E400515489 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB3C012950D1A700515489 /* ContentView.swift */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | 336C5FDE25CC9F1600230C37 /* OutlineViewDraggingExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutlineViewDraggingExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 19 | 336C600025CCAEDA00230C37 /* OutlineView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OutlineView; path = ../..; sourceTree = ""; }; 20 | 94899316296308A80018C5EA /* OutlineViewDraggingExample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OutlineViewDraggingExample.xcconfig; sourceTree = ""; }; 21 | 94DB3BFA2950D1A700515489 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; 22 | 94DB3BFB2950D1A700515489 /* OutlineViewDraggingExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OutlineViewDraggingExample.entitlements; sourceTree = ""; }; 23 | 94DB3BFC2950D1A700515489 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 24 | 94DB3BFD2950D1A700515489 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; 25 | 94DB3BFF2950D1A700515489 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 26 | 94DB3C002950D1A700515489 /* OutlineViewDraggingExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineViewDraggingExampleApp.swift; sourceTree = ""; }; 27 | 94DB3C012950D1A700515489 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 28 | 94DB3C022950D1A700515489 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | 336C5FDB25CC9F1500230C37 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | 336C600325CCAEE700230C37 /* OutlineView in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | 336C5FD525CC9F1500230C37 = { 44 | isa = PBXGroup; 45 | children = ( 46 | 336C600025CCAEDA00230C37 /* OutlineView */, 47 | 94DB3BF92950D1A700515489 /* OutlineViewDraggingExample */, 48 | 336C5FDF25CC9F1600230C37 /* Products */, 49 | 336C5FF825CC9F7600230C37 /* Frameworks */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | 336C5FDF25CC9F1600230C37 /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | 336C5FDE25CC9F1600230C37 /* OutlineViewDraggingExample.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | 336C5FF825CC9F7600230C37 /* Frameworks */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | ); 65 | name = Frameworks; 66 | sourceTree = ""; 67 | }; 68 | 94DB3BF92950D1A700515489 /* OutlineViewDraggingExample */ = { 69 | isa = PBXGroup; 70 | children = ( 71 | 94DB3BFC2950D1A700515489 /* Assets.xcassets */, 72 | 94DB3C012950D1A700515489 /* ContentView.swift */, 73 | 94DB3BFA2950D1A700515489 /* FileItemView.swift */, 74 | 94DB3C022950D1A700515489 /* Info.plist */, 75 | 94DB3BFB2950D1A700515489 /* OutlineViewDraggingExample.entitlements */, 76 | 94DB3C002950D1A700515489 /* OutlineViewDraggingExampleApp.swift */, 77 | 94DB3BFE2950D1A700515489 /* Preview Content */, 78 | 94DB3BFD2950D1A700515489 /* ViewModel.swift */, 79 | 94899316296308A80018C5EA /* OutlineViewDraggingExample.xcconfig */, 80 | ); 81 | path = OutlineViewDraggingExample; 82 | sourceTree = ""; 83 | }; 84 | 94DB3BFE2950D1A700515489 /* Preview Content */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | 94DB3BFF2950D1A700515489 /* Preview Assets.xcassets */, 88 | ); 89 | path = "Preview Content"; 90 | sourceTree = ""; 91 | }; 92 | /* End PBXGroup section */ 93 | 94 | /* Begin PBXNativeTarget section */ 95 | 336C5FDD25CC9F1500230C37 /* OutlineViewDraggingExample */ = { 96 | isa = PBXNativeTarget; 97 | buildConfigurationList = 336C5FEE25CC9F1800230C37 /* Build configuration list for PBXNativeTarget "OutlineViewDraggingExample" */; 98 | buildPhases = ( 99 | 336C5FDA25CC9F1500230C37 /* Sources */, 100 | 336C5FDB25CC9F1500230C37 /* Frameworks */, 101 | 336C5FDC25CC9F1500230C37 /* Resources */, 102 | ); 103 | buildRules = ( 104 | ); 105 | dependencies = ( 106 | ); 107 | name = OutlineViewDraggingExample; 108 | packageProductDependencies = ( 109 | 336C600225CCAEE700230C37 /* OutlineView */, 110 | ); 111 | productName = OutlineViewExample; 112 | productReference = 336C5FDE25CC9F1600230C37 /* OutlineViewDraggingExample.app */; 113 | productType = "com.apple.product-type.application"; 114 | }; 115 | /* End PBXNativeTarget section */ 116 | 117 | /* Begin PBXProject section */ 118 | 336C5FD625CC9F1500230C37 /* Project object */ = { 119 | isa = PBXProject; 120 | attributes = { 121 | LastSwiftUpdateCheck = 1220; 122 | LastUpgradeCheck = 1220; 123 | TargetAttributes = { 124 | 336C5FDD25CC9F1500230C37 = { 125 | CreatedOnToolsVersion = 12.2; 126 | }; 127 | }; 128 | }; 129 | buildConfigurationList = 336C5FD925CC9F1500230C37 /* Build configuration list for PBXProject "OutlineViewDraggingExample" */; 130 | compatibilityVersion = "Xcode 9.3"; 131 | developmentRegion = en; 132 | hasScannedForEncodings = 0; 133 | knownRegions = ( 134 | en, 135 | Base, 136 | ); 137 | mainGroup = 336C5FD525CC9F1500230C37; 138 | productRefGroup = 336C5FDF25CC9F1600230C37 /* Products */; 139 | projectDirPath = ""; 140 | projectRoot = ""; 141 | targets = ( 142 | 336C5FDD25CC9F1500230C37 /* OutlineViewDraggingExample */, 143 | ); 144 | }; 145 | /* End PBXProject section */ 146 | 147 | /* Begin PBXResourcesBuildPhase section */ 148 | 336C5FDC25CC9F1500230C37 /* Resources */ = { 149 | isa = PBXResourcesBuildPhase; 150 | buildActionMask = 2147483647; 151 | files = ( 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXResourcesBuildPhase section */ 156 | 157 | /* Begin PBXSourcesBuildPhase section */ 158 | 336C5FDA25CC9F1500230C37 /* Sources */ = { 159 | isa = PBXSourcesBuildPhase; 160 | buildActionMask = 2147483647; 161 | files = ( 162 | 94DB3C052950D3E200515489 /* FileItemView.swift in Sources */, 163 | 94DB3C032950D3DB00515489 /* ViewModel.swift in Sources */, 164 | 94DB3C062950D3E400515489 /* ContentView.swift in Sources */, 165 | 94DB3C042950D3DF00515489 /* OutlineViewDraggingExampleApp.swift in Sources */, 166 | ); 167 | runOnlyForDeploymentPostprocessing = 0; 168 | }; 169 | /* End PBXSourcesBuildPhase section */ 170 | 171 | /* Begin XCBuildConfiguration section */ 172 | 336C5FEC25CC9F1800230C37 /* Debug */ = { 173 | isa = XCBuildConfiguration; 174 | baseConfigurationReference = 94899316296308A80018C5EA /* OutlineViewDraggingExample.xcconfig */; 175 | buildSettings = { 176 | ALWAYS_SEARCH_USER_PATHS = NO; 177 | CLANG_ANALYZER_NONNULL = YES; 178 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 179 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 180 | CLANG_CXX_LIBRARY = "libc++"; 181 | CLANG_ENABLE_MODULES = YES; 182 | CLANG_ENABLE_OBJC_ARC = YES; 183 | CLANG_ENABLE_OBJC_WEAK = YES; 184 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 185 | CLANG_WARN_BOOL_CONVERSION = YES; 186 | CLANG_WARN_COMMA = YES; 187 | CLANG_WARN_CONSTANT_CONVERSION = YES; 188 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 189 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 190 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 191 | CLANG_WARN_EMPTY_BODY = YES; 192 | CLANG_WARN_ENUM_CONVERSION = YES; 193 | CLANG_WARN_INFINITE_RECURSION = YES; 194 | CLANG_WARN_INT_CONVERSION = YES; 195 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 196 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 197 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 198 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 199 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 200 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 201 | CLANG_WARN_STRICT_PROTOTYPES = YES; 202 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 203 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 204 | CLANG_WARN_UNREACHABLE_CODE = YES; 205 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 206 | COPY_PHASE_STRIP = NO; 207 | DEBUG_INFORMATION_FORMAT = dwarf; 208 | ENABLE_STRICT_OBJC_MSGSEND = YES; 209 | ENABLE_TESTABILITY = 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 | MACOSX_DEPLOYMENT_TARGET = 11.0; 225 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 226 | MTL_FAST_MATH = YES; 227 | ONLY_ACTIVE_ARCH = YES; 228 | SDKROOT = macosx; 229 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 230 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 231 | }; 232 | name = Debug; 233 | }; 234 | 336C5FED25CC9F1800230C37 /* Release */ = { 235 | isa = XCBuildConfiguration; 236 | baseConfigurationReference = 94899316296308A80018C5EA /* OutlineViewDraggingExample.xcconfig */; 237 | buildSettings = { 238 | ALWAYS_SEARCH_USER_PATHS = NO; 239 | CLANG_ANALYZER_NONNULL = YES; 240 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 241 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 242 | CLANG_CXX_LIBRARY = "libc++"; 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 | GCC_C_LANGUAGE_STANDARD = gnu11; 273 | GCC_NO_COMMON_BLOCKS = YES; 274 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 275 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 276 | GCC_WARN_UNDECLARED_SELECTOR = YES; 277 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 278 | GCC_WARN_UNUSED_FUNCTION = YES; 279 | GCC_WARN_UNUSED_VARIABLE = YES; 280 | MACOSX_DEPLOYMENT_TARGET = 11.0; 281 | MTL_ENABLE_DEBUG_INFO = NO; 282 | MTL_FAST_MATH = YES; 283 | SDKROOT = macosx; 284 | SWIFT_COMPILATION_MODE = wholemodule; 285 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 286 | }; 287 | name = Release; 288 | }; 289 | 336C5FEF25CC9F1800230C37 /* Debug */ = { 290 | isa = XCBuildConfiguration; 291 | buildSettings = { 292 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 293 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 294 | CODE_SIGN_ENTITLEMENTS = OutlineViewDraggingExample/OutlineViewDraggingExample.entitlements; 295 | CODE_SIGN_STYLE = Automatic; 296 | COMBINE_HIDPI_IMAGES = YES; 297 | DEVELOPMENT_ASSET_PATHS = "\"OutlineViewDraggingExample/Preview Content\""; 298 | ENABLE_HARDENED_RUNTIME = YES; 299 | ENABLE_PREVIEWS = YES; 300 | INFOPLIST_FILE = OutlineViewDraggingExample/Info.plist; 301 | LD_RUNPATH_SEARCH_PATHS = ( 302 | "$(inherited)", 303 | "@executable_path/../Frameworks", 304 | ); 305 | MACOSX_DEPLOYMENT_TARGET = 11.0; 306 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.OutlineViewDraggingExample${SAMPLE_CODE_DISAMBIGUATOR}"; 307 | PRODUCT_NAME = "$(TARGET_NAME)"; 308 | SWIFT_VERSION = 5.0; 309 | }; 310 | name = Debug; 311 | }; 312 | 336C5FF025CC9F1800230C37 /* Release */ = { 313 | isa = XCBuildConfiguration; 314 | buildSettings = { 315 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 316 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 317 | CODE_SIGN_ENTITLEMENTS = OutlineViewDraggingExample/OutlineViewDraggingExample.entitlements; 318 | CODE_SIGN_STYLE = Automatic; 319 | COMBINE_HIDPI_IMAGES = YES; 320 | DEVELOPMENT_ASSET_PATHS = "\"OutlineViewDraggingExample/Preview Content\""; 321 | ENABLE_HARDENED_RUNTIME = YES; 322 | ENABLE_PREVIEWS = YES; 323 | INFOPLIST_FILE = OutlineViewDraggingExample/Info.plist; 324 | LD_RUNPATH_SEARCH_PATHS = ( 325 | "$(inherited)", 326 | "@executable_path/../Frameworks", 327 | ); 328 | MACOSX_DEPLOYMENT_TARGET = 11.0; 329 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.OutlineViewDraggingExample${SAMPLE_CODE_DISAMBIGUATOR}"; 330 | PRODUCT_NAME = "$(TARGET_NAME)"; 331 | SWIFT_VERSION = 5.0; 332 | }; 333 | name = Release; 334 | }; 335 | /* End XCBuildConfiguration section */ 336 | 337 | /* Begin XCConfigurationList section */ 338 | 336C5FD925CC9F1500230C37 /* Build configuration list for PBXProject "OutlineViewDraggingExample" */ = { 339 | isa = XCConfigurationList; 340 | buildConfigurations = ( 341 | 336C5FEC25CC9F1800230C37 /* Debug */, 342 | 336C5FED25CC9F1800230C37 /* Release */, 343 | ); 344 | defaultConfigurationIsVisible = 0; 345 | defaultConfigurationName = Release; 346 | }; 347 | 336C5FEE25CC9F1800230C37 /* Build configuration list for PBXNativeTarget "OutlineViewDraggingExample" */ = { 348 | isa = XCConfigurationList; 349 | buildConfigurations = ( 350 | 336C5FEF25CC9F1800230C37 /* Debug */, 351 | 336C5FF025CC9F1800230C37 /* Release */, 352 | ); 353 | defaultConfigurationIsVisible = 0; 354 | defaultConfigurationName = Release; 355 | }; 356 | /* End XCConfigurationList section */ 357 | 358 | /* Begin XCSwiftPackageProductDependency section */ 359 | 336C600225CCAEE700230C37 /* OutlineView */ = { 360 | isa = XCSwiftPackageProductDependency; 361 | productName = OutlineView; 362 | }; 363 | /* End XCSwiftPackageProductDependency section */ 364 | }; 365 | rootObject = 336C5FD625CC9F1500230C37 /* Project object */; 366 | } 367 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/xcshareddata/xcschemes/OutlineViewDraggingExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/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 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import SwiftUI 9 | import OutlineView 10 | import Cocoa 11 | 12 | struct ContentView: View { 13 | @Environment(\.colorScheme) var colorScheme 14 | 15 | @StateObject var dataSource = sampleDataSource() 16 | @State var selection: FileItem? 17 | @State var separatorColor: Color = Color(NSColor.separatorColor) 18 | @State var separatorEnabled = false 19 | 20 | var body: some View { 21 | VStack { 22 | outlineView 23 | Divider() 24 | configBar 25 | } 26 | .background( 27 | colorScheme == .light 28 | ? Color(NSColor.textBackgroundColor) 29 | : Color.clear 30 | ) 31 | } 32 | 33 | var outlineView: some View { 34 | OutlineView( 35 | dataSource.rootData, 36 | selection: $selection, 37 | children: dataSource.childrenOfItem, 38 | separatorInsets: { fileItem in 39 | NSEdgeInsets( 40 | top: 0, 41 | left: 23, 42 | bottom: 0, 43 | right: 0) 44 | } 45 | ) { fileItem in 46 | FileItemView(fileItem: fileItem) 47 | } 48 | .outlineViewStyle(.inset) 49 | .outlineViewIndentation(20) 50 | .rowSeparator(separatorEnabled ? .visible : .hidden) 51 | .rowSeparatorColor(NSColor(separatorColor)) 52 | .dragDataSource { 53 | guard let encodedID = try? JSONEncoder().encode($0.id) 54 | else { return nil } 55 | let pbItem = NSPasteboardItem() 56 | pbItem.setData(encodedID, forType: .outlineViewItem) 57 | return pbItem 58 | } 59 | .onDrop(of: dataSource.pasteboardTypes, receiver: dataSource) 60 | } 61 | 62 | var configBar: some View { 63 | HStack { 64 | Spacer() 65 | ColorPicker( 66 | "Set separator color:", 67 | selection: $separatorColor) 68 | Button( 69 | "Toggle separator", 70 | action: { separatorEnabled.toggle() }) 71 | } 72 | .padding([.leading, .bottom, .trailing], 8) 73 | } 74 | 75 | } 76 | 77 | struct ContentView_Previews: PreviewProvider { 78 | static var previews: some View { 79 | ContentView() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/FileItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileItemView.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import Cocoa 9 | 10 | class FileItemView: NSTableCellView { 11 | init(fileItem: FileItem) { 12 | let field = NSTextField(string: fileItem.description) 13 | field.isEditable = false 14 | field.isSelectable = false 15 | field.isBezeled = false 16 | field.drawsBackground = false 17 | field.usesSingleLineMode = false 18 | field.cell?.wraps = true 19 | field.cell?.isScrollable = false 20 | 21 | super.init(frame: .zero) 22 | 23 | addSubview(field) 24 | field.translatesAutoresizingMaskIntoConstraints = false 25 | field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 26 | NSLayoutConstraint.activate([ 27 | field.leadingAnchor.constraint(equalTo: leadingAnchor), 28 | field.trailingAnchor.constraint(equalTo: trailingAnchor), 29 | field.topAnchor.constraint(equalTo: topAnchor, constant: 4), 30 | field.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), 31 | ]) 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSMinimumSystemVersion 22 | $(MACOSX_DEPLOYMENT_TARGET) 23 | 24 | 25 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/OutlineViewDraggingExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/OutlineViewDraggingExample.xcconfig: -------------------------------------------------------------------------------- 1 | SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM} 2 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/OutlineViewDraggingExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutlineViewExampleApp.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct OutlineViewDraggingExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Ryan Linn on 11/20/22. 6 | // 7 | 8 | import Cocoa 9 | import OutlineView 10 | 11 | func sampleDataSource() -> OutlineSampleViewModel { 12 | let fido = FileItem(fileName: "Fido") 13 | let chip = FileItem(fileName: "Chip") 14 | let rover = FileItem(fileName: "Rover") 15 | let spot = FileItem(fileName: "Spot") 16 | 17 | let fluffy = FileItem(fileName: "Fluffy") 18 | let fang = FileItem(fileName: "Fang") 19 | let tootsie = FileItem(fileName: "Tootsie") 20 | let milo = FileItem(fileName: "Milo") 21 | 22 | let bart = FileItem(fileName: "Bart") 23 | let leo = FileItem(fileName: "Leo") 24 | let lucy = FileItem(fileName: "Lucy") 25 | let dia = FileItem(fileName: "Dia") 26 | let templeton = FileItem(fileName: "Templeton") 27 | let chewy = FileItem(fileName: "Chewy") 28 | let pizza = FileItem(fileName: "Pizza") 29 | 30 | let dogsFolder = FileItem(folderName: "Dogs") 31 | let catsFolder = FileItem(folderName: "Cats") 32 | let otherFolder = FileItem(folderName: "Other") 33 | let ratsFolder = FileItem(folderName: "Rats") 34 | let fishFolder = FileItem(folderName: "Fish") 35 | let turtlesFolder = FileItem(folderName: "Turtles") 36 | 37 | let roots = [ 38 | dogsFolder, 39 | catsFolder, 40 | otherFolder 41 | ] 42 | 43 | let childDirectory = [ 44 | dogsFolder: [fido, chip, rover, spot], 45 | catsFolder: [fluffy, fang, tootsie, milo], 46 | otherFolder: [ratsFolder, fishFolder, turtlesFolder, chewy, pizza], 47 | ratsFolder: [templeton, leo, bart], 48 | fishFolder: [dia, lucy], 49 | turtlesFolder: [] 50 | ] 51 | let childlessItems = [fido, chip, rover, spot, fluffy, fang, tootsie, milo, bart, leo, lucy, dia, templeton, chewy, pizza] 52 | let theChilds = childDirectory.map { ($0.key, $0.value) } + childlessItems.map { ($0, nil) } 53 | 54 | return OutlineSampleViewModel(rootData: roots, childrenDirectory: theChilds) 55 | } 56 | 57 | extension NSPasteboard.PasteboardType { 58 | static var outlineViewItem: Self { 59 | .init("OutlineView.OutlineItem") 60 | } 61 | } 62 | 63 | struct FileItem: Hashable, Identifiable, CustomStringConvertible { 64 | var name: String 65 | var isFolder: Bool 66 | 67 | var id: String { name } 68 | 69 | var description: String { 70 | if !isFolder { 71 | return "📄 \(name)" 72 | } else { 73 | return "📁 \(name)" 74 | } 75 | } 76 | 77 | init(folderName: String) { 78 | self.name = folderName 79 | isFolder = true 80 | } 81 | 82 | init(fileName: String) { 83 | self.name = fileName 84 | isFolder = false 85 | } 86 | 87 | static func == (lhs: FileItem, rhs: FileItem) -> Bool { 88 | lhs.id == rhs.id 89 | } 90 | 91 | func hash(into hasher: inout Hasher) { 92 | hasher.combine(id) 93 | } 94 | 95 | } 96 | 97 | class OutlineSampleViewModel: ObservableObject { 98 | 99 | @Published var rootData: [FileItem] 100 | private var dataAndChildren: [(item: FileItem, children: [FileItem]?)] 101 | 102 | var pasteboardTypes: [NSPasteboard.PasteboardType] { 103 | [ 104 | .outlineViewItem, 105 | .fileURL, 106 | .fileContents, 107 | .string 108 | ] 109 | } 110 | 111 | init(rootData: [FileItem], childrenDirectory: [(FileItem, [FileItem]?)]) { 112 | self.dataAndChildren = childrenDirectory 113 | self.rootData = rootData 114 | } 115 | 116 | func childrenOfItem(_ item: FileItem) -> [FileItem]? { 117 | getChildrenOfID(item.id) 118 | } 119 | 120 | private func getItemWithID(_ identifier: FileItem.ID) -> FileItem? { 121 | dataAndChildren.first(where: { $0.item.id == identifier })?.item 122 | } 123 | 124 | private func getChildrenOfID(_ identifier: FileItem.ID) -> [FileItem]? { 125 | dataAndChildren.first(where: { $0.item.id == identifier })?.children 126 | } 127 | 128 | private func getParentOfID(_ identifier: FileItem.ID) -> FileItem? { 129 | dataAndChildren.first(where: { $0.children?.map(\.id).contains(identifier) ?? false })?.item 130 | } 131 | 132 | private func item(_ item: FileItem, isDescendentOf parent: FileItem) -> Bool { 133 | 134 | var currentParent = getParentOfID(item.id) 135 | while currentParent != nil { 136 | if currentParent == parent { 137 | return true 138 | } else { 139 | currentParent = getParentOfID(currentParent!.id) 140 | } 141 | } 142 | 143 | return false 144 | } 145 | 146 | } 147 | 148 | extension OutlineSampleViewModel: DropReceiver { 149 | func readPasteboard(item: NSPasteboardItem) -> DraggedItem? { 150 | guard let pasteboardType = item.availableType(from: pasteboardTypes) 151 | else { return nil } 152 | 153 | var result: FileItem? = nil 154 | switch pasteboardType { 155 | case .outlineViewItem: 156 | let encodedData = item.data(forType: pasteboardType) 157 | let decodedID = encodedData.flatMap { try? JSONDecoder().decode(String.self, from: $0) } 158 | result = decodedID.flatMap { getItemWithID($0) } 159 | case .fileURL: 160 | let filePath = item.string(forType: pasteboardType) 161 | let fileUrl = filePath.flatMap { URL(string: $0) } 162 | let fileName = fileUrl?.standardized.lastPathComponent 163 | let isDirectory = (try? fileUrl?.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false 164 | if isDirectory { 165 | result = fileName.map { FileItem(folderName: $0) } 166 | } else { 167 | result = fileName.map { FileItem(fileName: $0) } 168 | } 169 | case .fileContents: 170 | let fileData = item.data(forType: pasteboardType) ?? Data() 171 | let sizeOfData = Int64(fileData.count) 172 | let someFileName = ByteCountFormatter.string(fromByteCount: sizeOfData, countStyle: .file) 173 | result = FileItem(fileName: someFileName) 174 | case .string: 175 | let stringValue = item.string(forType: pasteboardType) 176 | result = stringValue.map { FileItem(fileName: $0) } 177 | default: 178 | break 179 | } 180 | 181 | return result.map { ($0, pasteboardType) } 182 | } 183 | 184 | func validateDrop(target: DropTarget) -> ValidationResult { 185 | 186 | // Only dragging first item. Haven't dealt with how to handle multi-drags 187 | guard let singleDraggedItem = target.items.first 188 | else { return .deny } 189 | 190 | // Moving item into existing object? 191 | if let intoItem = target.intoElement { 192 | 193 | // Moving item into itself? Deny 194 | guard intoItem != singleDraggedItem.item else { 195 | return .deny 196 | } 197 | 198 | // Moving item into a non-folder? Deny. 199 | guard intoItem.isFolder else { 200 | return .deny 201 | } 202 | 203 | } 204 | 205 | // Calculate existing array of items we're dropping into 206 | let targetChildren: [FileItem] 207 | if target.intoElement == nil { 208 | // intoElement == nil means we're dragging into the root 209 | targetChildren = rootData 210 | } else if let childrenOfObject = getChildrenOfID(target.intoElement!.id) { 211 | // intoElement has children, so dragging into a folder 212 | targetChildren = childrenOfObject 213 | } else { 214 | // intoElement has no children... this shouldn't happen 215 | return .deny 216 | } 217 | 218 | // Deny moving item onto itself: 219 | if !targetChildren.isEmpty, 220 | let dropIndex = target.childIndex, 221 | let itemAlreadyAtIndex = targetChildren.firstIndex(of: singleDraggedItem.item), 222 | itemAlreadyAtIndex == dropIndex || itemAlreadyAtIndex == dropIndex - 1 223 | { 224 | return .deny 225 | } 226 | 227 | // Deny moving folder into itself or one of its sub-directories 228 | if let targetFolder = target.intoElement, 229 | targetFolder.isFolder, 230 | item(targetFolder, isDescendentOf: singleDraggedItem.item) 231 | { 232 | return .deny 233 | } 234 | 235 | // If moving into root, and no childIndex given, redirect to end of root 236 | if target.intoElement == nil, 237 | target.childIndex == nil 238 | { 239 | // Move if coming from within OutlineView, copy otherwise. 240 | if singleDraggedItem.type == .outlineViewItem { 241 | return .moveRedirect(item: nil, childIndex: rootData.count) 242 | } else { 243 | return .copyRedirect(item: nil, childIndex: rootData.count) 244 | } 245 | } 246 | 247 | // If moving into an unexpanded target folder but has a given child index, 248 | // redirect to remove the childIndex and add to the end of that folder's children. 249 | if target.intoElement != nil, 250 | !target.isItemExpanded(target.intoElement!), 251 | target.childIndex != nil 252 | { 253 | // Move if coming from within OutlineView, copy otherwise. 254 | if singleDraggedItem.type == .outlineViewItem { 255 | return .moveRedirect(item: target.intoElement, childIndex: nil) 256 | } else { 257 | return .copyRedirect(item: target.intoElement, childIndex: nil) 258 | } 259 | } 260 | 261 | // All tests have passed, so validate the move or copy as is. 262 | if singleDraggedItem.type == .outlineViewItem { 263 | return .move 264 | } else { 265 | return .copy 266 | } 267 | } 268 | 269 | func acceptDrop(target: DropTarget) -> Bool { 270 | 271 | let movedItem = target.items[0].item 272 | 273 | // get existing index of moved item in order to remove it before re-inserting to target 274 | let previousIndex: (FileItem?, Int)? 275 | if let rootIndex = rootData.firstIndex(of: movedItem) { 276 | previousIndex = (nil, rootIndex) 277 | } else if let (parent, siblings) = dataAndChildren.first(where: { $0.children?.contains(movedItem) ?? false }) { 278 | previousIndex = (parent, siblings!.firstIndex(of: movedItem)!) 279 | } else { 280 | previousIndex = nil 281 | } 282 | 283 | // if an item is moved to a higher index in the same folder as it came from, 284 | // we need to subtract 1 from the insertion index when inserting after this step 285 | var subtractFromInsertIndex = 0 286 | if let previousIndex { 287 | // Remove previous item. 288 | if previousIndex.0 == nil { 289 | rootData.remove(at: previousIndex.1) 290 | } else { 291 | let idx = dataAndChildren.firstIndex(where: { $0.item.id == previousIndex.0!.id })! 292 | dataAndChildren[idx].children?.remove(at: previousIndex.1) 293 | } 294 | if previousIndex.0 == target.intoElement, 295 | let newIndex = target.childIndex, 296 | newIndex > previousIndex.1 297 | { 298 | subtractFromInsertIndex = 1 299 | } 300 | } 301 | 302 | if let intoItem = target.intoElement, 303 | let dataChildIndex = dataAndChildren.firstIndex(where: { $0.item.id == intoItem.id }) 304 | { 305 | // Dropping into a folder 306 | if let basicIndex = target.childIndex { 307 | let insertIndex = basicIndex - subtractFromInsertIndex 308 | dataAndChildren[dataChildIndex].children?.insert(movedItem, at: insertIndex) 309 | } else { 310 | dataAndChildren[dataChildIndex].children?.append(movedItem) 311 | } 312 | } else if target.intoElement == nil { 313 | // Dropping into Root 314 | if let basicIndex = target.childIndex { 315 | let insertIndex = basicIndex - subtractFromInsertIndex 316 | rootData.insert(movedItem, at: insertIndex) 317 | } else { 318 | rootData.append(movedItem) 319 | } 320 | } 321 | 322 | // Update dataAndChildren if object was added from outside: 323 | if dataAndChildren.firstIndex(where: { $0.item.id == movedItem.id }) == nil { 324 | dataAndChildren.append((movedItem, movedItem.isFolder ? [] : nil)) 325 | } 326 | 327 | objectWillChange.send() 328 | return true 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 336C5FE225CC9F1600230C37 /* OutlineViewExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336C5FE125CC9F1600230C37 /* OutlineViewExampleApp.swift */; }; 11 | 336C5FE425CC9F1600230C37 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336C5FE325CC9F1600230C37 /* ContentView.swift */; }; 12 | 336C5FE625CC9F1800230C37 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 336C5FE525CC9F1800230C37 /* Assets.xcassets */; }; 13 | 336C5FE925CC9F1800230C37 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 336C5FE825CC9F1800230C37 /* Preview Assets.xcassets */; }; 14 | 336C5FFD25CCA8DA00230C37 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336C5FFC25CCA8D900230C37 /* FileItemView.swift */; }; 15 | 336C600325CCAEE700230C37 /* OutlineView in Frameworks */ = {isa = PBXBuildFile; productRef = 336C600225CCAEE700230C37 /* OutlineView */; }; 16 | /* End PBXBuildFile section */ 17 | 18 | /* Begin PBXFileReference section */ 19 | 336C5FDE25CC9F1600230C37 /* OutlineViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutlineViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20 | 336C5FE125CC9F1600230C37 /* OutlineViewExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineViewExampleApp.swift; sourceTree = ""; }; 21 | 336C5FE325CC9F1600230C37 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 22 | 336C5FE525CC9F1800230C37 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 23 | 336C5FE825CC9F1800230C37 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 24 | 336C5FEA25CC9F1800230C37 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25 | 336C5FEB25CC9F1800230C37 /* OutlineViewExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OutlineViewExample.entitlements; sourceTree = ""; }; 26 | 336C5FFC25CCA8D900230C37 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; 27 | 336C600025CCAEDA00230C37 /* OutlineView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OutlineView; path = ../..; sourceTree = ""; }; 28 | 94899318296309C20018C5EA /* OutlineViewExample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OutlineViewExample.xcconfig; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | 336C5FDB25CC9F1500230C37 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | 336C600325CCAEE700230C37 /* OutlineView in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | 336C5FD525CC9F1500230C37 = { 44 | isa = PBXGroup; 45 | children = ( 46 | 336C600025CCAEDA00230C37 /* OutlineView */, 47 | 336C5FE025CC9F1600230C37 /* OutlineViewExample */, 48 | 336C5FDF25CC9F1600230C37 /* Products */, 49 | 336C5FF825CC9F7600230C37 /* Frameworks */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | 336C5FDF25CC9F1600230C37 /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | 336C5FDE25CC9F1600230C37 /* OutlineViewExample.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | 336C5FE025CC9F1600230C37 /* OutlineViewExample */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | 336C5FE125CC9F1600230C37 /* OutlineViewExampleApp.swift */, 65 | 336C5FE325CC9F1600230C37 /* ContentView.swift */, 66 | 336C5FFC25CCA8D900230C37 /* FileItemView.swift */, 67 | 336C5FE525CC9F1800230C37 /* Assets.xcassets */, 68 | 336C5FEA25CC9F1800230C37 /* Info.plist */, 69 | 336C5FEB25CC9F1800230C37 /* OutlineViewExample.entitlements */, 70 | 336C5FE725CC9F1800230C37 /* Preview Content */, 71 | 94899318296309C20018C5EA /* OutlineViewExample.xcconfig */, 72 | ); 73 | path = OutlineViewExample; 74 | sourceTree = ""; 75 | }; 76 | 336C5FE725CC9F1800230C37 /* Preview Content */ = { 77 | isa = PBXGroup; 78 | children = ( 79 | 336C5FE825CC9F1800230C37 /* Preview Assets.xcassets */, 80 | ); 81 | path = "Preview Content"; 82 | sourceTree = ""; 83 | }; 84 | 336C5FF825CC9F7600230C37 /* Frameworks */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | ); 88 | name = Frameworks; 89 | sourceTree = ""; 90 | }; 91 | /* End PBXGroup section */ 92 | 93 | /* Begin PBXNativeTarget section */ 94 | 336C5FDD25CC9F1500230C37 /* OutlineViewExample */ = { 95 | isa = PBXNativeTarget; 96 | buildConfigurationList = 336C5FEE25CC9F1800230C37 /* Build configuration list for PBXNativeTarget "OutlineViewExample" */; 97 | buildPhases = ( 98 | 336C5FDA25CC9F1500230C37 /* Sources */, 99 | 336C5FDB25CC9F1500230C37 /* Frameworks */, 100 | 336C5FDC25CC9F1500230C37 /* Resources */, 101 | ); 102 | buildRules = ( 103 | ); 104 | dependencies = ( 105 | ); 106 | name = OutlineViewExample; 107 | packageProductDependencies = ( 108 | 336C600225CCAEE700230C37 /* OutlineView */, 109 | ); 110 | productName = OutlineViewExample; 111 | productReference = 336C5FDE25CC9F1600230C37 /* OutlineViewExample.app */; 112 | productType = "com.apple.product-type.application"; 113 | }; 114 | /* End PBXNativeTarget section */ 115 | 116 | /* Begin PBXProject section */ 117 | 336C5FD625CC9F1500230C37 /* Project object */ = { 118 | isa = PBXProject; 119 | attributes = { 120 | LastSwiftUpdateCheck = 1220; 121 | LastUpgradeCheck = 1220; 122 | TargetAttributes = { 123 | 336C5FDD25CC9F1500230C37 = { 124 | CreatedOnToolsVersion = 12.2; 125 | }; 126 | }; 127 | }; 128 | buildConfigurationList = 336C5FD925CC9F1500230C37 /* Build configuration list for PBXProject "OutlineViewExample" */; 129 | compatibilityVersion = "Xcode 9.3"; 130 | developmentRegion = en; 131 | hasScannedForEncodings = 0; 132 | knownRegions = ( 133 | en, 134 | Base, 135 | ); 136 | mainGroup = 336C5FD525CC9F1500230C37; 137 | productRefGroup = 336C5FDF25CC9F1600230C37 /* Products */; 138 | projectDirPath = ""; 139 | projectRoot = ""; 140 | targets = ( 141 | 336C5FDD25CC9F1500230C37 /* OutlineViewExample */, 142 | ); 143 | }; 144 | /* End PBXProject section */ 145 | 146 | /* Begin PBXResourcesBuildPhase section */ 147 | 336C5FDC25CC9F1500230C37 /* Resources */ = { 148 | isa = PBXResourcesBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | 336C5FE925CC9F1800230C37 /* Preview Assets.xcassets in Resources */, 152 | 336C5FE625CC9F1800230C37 /* Assets.xcassets in Resources */, 153 | ); 154 | runOnlyForDeploymentPostprocessing = 0; 155 | }; 156 | /* End PBXResourcesBuildPhase section */ 157 | 158 | /* Begin PBXSourcesBuildPhase section */ 159 | 336C5FDA25CC9F1500230C37 /* Sources */ = { 160 | isa = PBXSourcesBuildPhase; 161 | buildActionMask = 2147483647; 162 | files = ( 163 | 336C5FFD25CCA8DA00230C37 /* FileItemView.swift in Sources */, 164 | 336C5FE425CC9F1600230C37 /* ContentView.swift in Sources */, 165 | 336C5FE225CC9F1600230C37 /* OutlineViewExampleApp.swift in Sources */, 166 | ); 167 | runOnlyForDeploymentPostprocessing = 0; 168 | }; 169 | /* End PBXSourcesBuildPhase section */ 170 | 171 | /* Begin XCBuildConfiguration section */ 172 | 336C5FEC25CC9F1800230C37 /* Debug */ = { 173 | isa = XCBuildConfiguration; 174 | baseConfigurationReference = 94899318296309C20018C5EA /* OutlineViewExample.xcconfig */; 175 | buildSettings = { 176 | ALWAYS_SEARCH_USER_PATHS = NO; 177 | CLANG_ANALYZER_NONNULL = YES; 178 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 179 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 180 | CLANG_CXX_LIBRARY = "libc++"; 181 | CLANG_ENABLE_MODULES = YES; 182 | CLANG_ENABLE_OBJC_ARC = YES; 183 | CLANG_ENABLE_OBJC_WEAK = YES; 184 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 185 | CLANG_WARN_BOOL_CONVERSION = YES; 186 | CLANG_WARN_COMMA = YES; 187 | CLANG_WARN_CONSTANT_CONVERSION = YES; 188 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 189 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 190 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 191 | CLANG_WARN_EMPTY_BODY = YES; 192 | CLANG_WARN_ENUM_CONVERSION = YES; 193 | CLANG_WARN_INFINITE_RECURSION = YES; 194 | CLANG_WARN_INT_CONVERSION = YES; 195 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 196 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 197 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 198 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 199 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 200 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 201 | CLANG_WARN_STRICT_PROTOTYPES = YES; 202 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 203 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 204 | CLANG_WARN_UNREACHABLE_CODE = YES; 205 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 206 | COPY_PHASE_STRIP = NO; 207 | DEBUG_INFORMATION_FORMAT = dwarf; 208 | ENABLE_STRICT_OBJC_MSGSEND = YES; 209 | ENABLE_TESTABILITY = 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 | MACOSX_DEPLOYMENT_TARGET = 11.0; 225 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 226 | MTL_FAST_MATH = YES; 227 | ONLY_ACTIVE_ARCH = YES; 228 | SDKROOT = macosx; 229 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 230 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 231 | }; 232 | name = Debug; 233 | }; 234 | 336C5FED25CC9F1800230C37 /* Release */ = { 235 | isa = XCBuildConfiguration; 236 | baseConfigurationReference = 94899318296309C20018C5EA /* OutlineViewExample.xcconfig */; 237 | buildSettings = { 238 | ALWAYS_SEARCH_USER_PATHS = NO; 239 | CLANG_ANALYZER_NONNULL = YES; 240 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 241 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 242 | CLANG_CXX_LIBRARY = "libc++"; 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 | GCC_C_LANGUAGE_STANDARD = gnu11; 273 | GCC_NO_COMMON_BLOCKS = YES; 274 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 275 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 276 | GCC_WARN_UNDECLARED_SELECTOR = YES; 277 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 278 | GCC_WARN_UNUSED_FUNCTION = YES; 279 | GCC_WARN_UNUSED_VARIABLE = YES; 280 | MACOSX_DEPLOYMENT_TARGET = 11.0; 281 | MTL_ENABLE_DEBUG_INFO = NO; 282 | MTL_FAST_MATH = YES; 283 | SDKROOT = macosx; 284 | SWIFT_COMPILATION_MODE = wholemodule; 285 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 286 | }; 287 | name = Release; 288 | }; 289 | 336C5FEF25CC9F1800230C37 /* Debug */ = { 290 | isa = XCBuildConfiguration; 291 | buildSettings = { 292 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 293 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 294 | CODE_SIGN_ENTITLEMENTS = OutlineViewExample/OutlineViewExample.entitlements; 295 | CODE_SIGN_STYLE = Automatic; 296 | COMBINE_HIDPI_IMAGES = YES; 297 | DEVELOPMENT_ASSET_PATHS = "\"OutlineViewExample/Preview Content\""; 298 | ENABLE_HARDENED_RUNTIME = YES; 299 | ENABLE_PREVIEWS = YES; 300 | INFOPLIST_FILE = OutlineViewExample/Info.plist; 301 | LD_RUNPATH_SEARCH_PATHS = ( 302 | "$(inherited)", 303 | "@executable_path/../Frameworks", 304 | ); 305 | MACOSX_DEPLOYMENT_TARGET = 11.0; 306 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.OutlineViewExample${SAMPLE_CODE_DISAMBIGUATOR}"; 307 | PRODUCT_NAME = "$(TARGET_NAME)"; 308 | SWIFT_VERSION = 5.0; 309 | }; 310 | name = Debug; 311 | }; 312 | 336C5FF025CC9F1800230C37 /* Release */ = { 313 | isa = XCBuildConfiguration; 314 | buildSettings = { 315 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 316 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 317 | CODE_SIGN_ENTITLEMENTS = OutlineViewExample/OutlineViewExample.entitlements; 318 | CODE_SIGN_STYLE = Automatic; 319 | COMBINE_HIDPI_IMAGES = YES; 320 | DEVELOPMENT_ASSET_PATHS = "\"OutlineViewExample/Preview Content\""; 321 | ENABLE_HARDENED_RUNTIME = YES; 322 | ENABLE_PREVIEWS = YES; 323 | INFOPLIST_FILE = OutlineViewExample/Info.plist; 324 | LD_RUNPATH_SEARCH_PATHS = ( 325 | "$(inherited)", 326 | "@executable_path/../Frameworks", 327 | ); 328 | MACOSX_DEPLOYMENT_TARGET = 11.0; 329 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.OutlineViewExample${SAMPLE_CODE_DISAMBIGUATOR}"; 330 | PRODUCT_NAME = "$(TARGET_NAME)"; 331 | SWIFT_VERSION = 5.0; 332 | }; 333 | name = Release; 334 | }; 335 | /* End XCBuildConfiguration section */ 336 | 337 | /* Begin XCConfigurationList section */ 338 | 336C5FD925CC9F1500230C37 /* Build configuration list for PBXProject "OutlineViewExample" */ = { 339 | isa = XCConfigurationList; 340 | buildConfigurations = ( 341 | 336C5FEC25CC9F1800230C37 /* Debug */, 342 | 336C5FED25CC9F1800230C37 /* Release */, 343 | ); 344 | defaultConfigurationIsVisible = 0; 345 | defaultConfigurationName = Release; 346 | }; 347 | 336C5FEE25CC9F1800230C37 /* Build configuration list for PBXNativeTarget "OutlineViewExample" */ = { 348 | isa = XCConfigurationList; 349 | buildConfigurations = ( 350 | 336C5FEF25CC9F1800230C37 /* Debug */, 351 | 336C5FF025CC9F1800230C37 /* Release */, 352 | ); 353 | defaultConfigurationIsVisible = 0; 354 | defaultConfigurationName = Release; 355 | }; 356 | /* End XCConfigurationList section */ 357 | 358 | /* Begin XCSwiftPackageProductDependency section */ 359 | 336C600225CCAEE700230C37 /* OutlineView */ = { 360 | isa = XCSwiftPackageProductDependency; 361 | productName = OutlineView; 362 | }; 363 | /* End XCSwiftPackageProductDependency section */ 364 | }; 365 | rootObject = 336C5FD625CC9F1500230C37 /* Project object */; 366 | } 367 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/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 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import SwiftUI 9 | import OutlineView 10 | import Cocoa 11 | 12 | struct FileItem: Hashable, Identifiable, CustomStringConvertible { 13 | var id = UUID() 14 | var name: String 15 | var children: [FileItem]? = nil 16 | var description: String { 17 | switch children { 18 | case nil: 19 | return "📄 \(name)" 20 | case .some(let children): 21 | return children.isEmpty ? "📂 \(name)" : "📁 \(name)" 22 | } 23 | } 24 | } 25 | 26 | let data = [ 27 | FileItem(name: "doc001.txt"), 28 | FileItem( 29 | name: "users", 30 | children: [ 31 | FileItem( 32 | name: "user1234", 33 | children: [ 34 | FileItem( 35 | name: "Photos", 36 | children: [ 37 | FileItem(name: "photo001.jpg"), 38 | FileItem(name: "photo002.jpg")]), 39 | FileItem( 40 | name: "Movies", 41 | children: [FileItem(name: "movie001.mp4")]), 42 | FileItem(name: "Documents", children: [])]), 43 | FileItem( 44 | name: "newuser", 45 | children: [FileItem(name: "Documents", children: [])]) 46 | ] 47 | ) 48 | ] 49 | 50 | struct ContentView: View { 51 | @Environment(\.colorScheme) var colorScheme 52 | 53 | @State var selection: FileItem? 54 | @State var separatorColor: Color = Color(NSColor.separatorColor) 55 | @State var separatorEnabled = false 56 | 57 | var body: some View { 58 | VStack { 59 | outlineView 60 | Divider() 61 | configBar 62 | } 63 | .background( 64 | colorScheme == .light 65 | ? Color(NSColor.textBackgroundColor) 66 | : Color.clear 67 | ) 68 | } 69 | 70 | var outlineView: some View { 71 | OutlineView( 72 | data, 73 | selection: $selection, 74 | children: \.children, 75 | separatorInsets: { fileItem in 76 | NSEdgeInsets( 77 | top: 0, 78 | left: 23, 79 | bottom: 0, 80 | right: 0) 81 | } 82 | ) { fileItem in 83 | FileItemView(fileItem: fileItem) 84 | } 85 | .outlineViewStyle(.inset) 86 | .outlineViewIndentation(20) 87 | .rowSeparator(separatorEnabled ? .visible : .hidden) 88 | .rowSeparatorColor(NSColor(separatorColor)) 89 | } 90 | 91 | var configBar: some View { 92 | HStack { 93 | Spacer() 94 | ColorPicker( 95 | "Set separator color:", 96 | selection: $separatorColor) 97 | Button( 98 | "Toggle separator", 99 | action: { separatorEnabled.toggle() }) 100 | } 101 | .padding([.leading, .bottom, .trailing], 8) 102 | } 103 | } 104 | 105 | struct ContentView_Previews: PreviewProvider { 106 | static var previews: some View { 107 | ContentView() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/FileItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileItemView.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import Cocoa 9 | 10 | class FileItemView: NSTableCellView { 11 | init(fileItem: FileItem) { 12 | let field = NSTextField(string: fileItem.description) 13 | field.isEditable = false 14 | field.isSelectable = false 15 | field.isBezeled = false 16 | field.drawsBackground = false 17 | field.usesSingleLineMode = false 18 | field.cell?.wraps = true 19 | field.cell?.isScrollable = false 20 | 21 | super.init(frame: .zero) 22 | 23 | addSubview(field) 24 | field.translatesAutoresizingMaskIntoConstraints = false 25 | field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 26 | NSLayoutConstraint.activate([ 27 | field.leadingAnchor.constraint(equalTo: leadingAnchor), 28 | field.trailingAnchor.constraint(equalTo: trailingAnchor), 29 | field.topAnchor.constraint(equalTo: topAnchor, constant: 4), 30 | field.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), 31 | ]) 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSMinimumSystemVersion 22 | $(MACOSX_DEPLOYMENT_TARGET) 23 | 24 | 25 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/OutlineViewExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/OutlineViewExample.xcconfig: -------------------------------------------------------------------------------- 1 | SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM} 2 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/OutlineViewExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutlineViewExampleApp.swift 3 | // OutlineViewExample 4 | // 5 | // Created by Samar Sunkaria on 2/4/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct OutlineViewExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/OutlineViewExample/OutlineViewExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sameesunkaria/OutlineView/661a32c6f6db1bbb4349048e3d0fcb6afa5a9d26/Examples/Screenshot.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Samar Sunkaria 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.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "OutlineView", 7 | products: [ 8 | .library( 9 | name: "OutlineView", 10 | targets: ["OutlineView"]), 11 | ], 12 | targets: [ 13 | .target( 14 | name: "OutlineView", 15 | dependencies: []), 16 | .testTarget( 17 | name: "OutlineViewTests", 18 | dependencies: ["OutlineView"]), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OutlineView for SwiftUI on macOS 2 | 3 | `OutlineView` is a SwiftUI view for macOS, which allows you to display hierarchical visual layouts (like directories and files) that can be expanded and collapsed. 4 | It provides a convenient wrapper around AppKit's `NSOutlineView`, similar to SwiftUI's `OutlineGroup` embedded in a `List` or a `List` with children. `OutlineView` provides it's own scroll view and doesn't have to be embedded in a `List`. 5 | 6 |

7 | Screenshot 8 |

9 | 10 | ## Installation 11 | 12 | You can install the `OutlineView` package using SwiftPM. 13 | 14 | ``` 15 | https://github.com/Sameesunkaria/OutlineView.git 16 | ``` 17 | 18 | ## Usage 19 | 20 | The API of the `OutlineView` is similar to the native SwiftUI `List` with children. However, there is one notable difference; `OutlineView` requires you to provide an `NSView` (preferably an `NSTableCellView`) as the content view. This API decision is discussed in the [caveats](#Caveats) section. 21 | 22 | In the following example, a tree structure of `FileItem` data offers a simplified view of a file system. Passing a sequence of root elements of this tree and the key path of its children allows you to quickly create a visual representation of the file system. 23 | 24 | A macOS app demonstrating this example can be found in the `Example` directory. 25 | 26 | ```swift 27 | struct FileItem: Hashable, Identifiable, CustomStringConvertible { 28 | // Each item in the hierarchy should be uniquely identified. 29 | var id = UUID() 30 | 31 | var name: String 32 | var children: [FileItem]? = nil 33 | var description: String { 34 | switch children { 35 | case nil: 36 | return "📄 \(name)" 37 | case .some(let children): 38 | return children.isEmpty ? "📂 \(name)" : "📁 \(name)" 39 | } 40 | } 41 | } 42 | 43 | let data = [ 44 | FileItem( 45 | name: "user1234", 46 | children: [ 47 | FileItem( 48 | name: "Photos", 49 | children: [ 50 | FileItem(name: "photo001.jpg"), 51 | FileItem(name: "photo002.jpg")]), 52 | FileItem( 53 | name: "Movies", 54 | children: [FileItem(name: "movie001.mp4")]), 55 | FileItem(name: "Documents", children: [])]), 56 | FileItem( 57 | name: "newuser", 58 | children: [FileItem(name: "Documents", children: [])]) 59 | ] 60 | 61 | @State var selection: FileItem? 62 | 63 | OutlineView(data, selection: $selection, children: \.children) { item in 64 | NSTextField(string: item.description) 65 | } 66 | ``` 67 | 68 | ### Customization 69 | 70 | #### Children 71 | There are two types of `.children` parameters in the `OutlineView` initializers. You either provide the children for an item using: 72 | - A `KeyPath` pointing to an optional `Sequence` of the same type as the root data. 73 | - A closure that returns an optional `Sequence` of the same type as the root data, based on the parent item. 74 | 75 | ```swift 76 | // By passing a KeyPath to the children: 77 | OutlineView(data, children: \.children, selection: $selection) { item in 78 | NSTextField(string: item.description) 79 | } 80 | 81 | // By providing a closure that returns the children: 82 | OutlineView(data, selection: $selection) { item in 83 | dataSource.childrenOfItem(item) 84 | } content: { item in 85 | NSTextField(string: item.description) 86 | } 87 | ``` 88 | 89 | #### Style 90 | You can customize the look of the `OutlineView` by providing a preferred style (`NSOutlineView.Style`) in the `outlineViewStyle` method. The default value is `.automatic`. 91 | 92 | ```swift 93 | OutlineView(data, selection: $selection, children: \.children) { item in 94 | NSTextField(string: item.description) 95 | } 96 | .outlineViewStyle(.sourceList) 97 | ``` 98 | 99 | #### Indentation 100 | 101 | You can customize the indentation width for the `OutlineView`. Each child will be indented by this width, from the parent's leading inset. The default value is `13.0`. 102 | 103 | ```swift 104 | OutlineView(data, selection: $selection, children: \.children) { item in 105 | NSTextField(string: item.description) 106 | } 107 | .outlineViewIndentation(20) 108 | ``` 109 | 110 | #### Displaying separators 111 | 112 | You can customize the `OutlineView` to display row separators by using the `rowSeparator` modifier. 113 | 114 | ```swift 115 | OutlineView(data, selection: $selection, children: \.children) { item in 116 | NSTextField(string: item.description) 117 | } 118 | .rowSeparator(.visible) 119 | ``` 120 | 121 | By default, macOS will attempt to draw separators with appropriate insets based on the style of the `OutlineView` and the contents of the cell. To customize the separator insets, you can use the initializer which takes `separatorInsets` as an argument. `separatorInsets` is a closure that returns the edge insets of a separator for the row displaying the provided data element. 122 | 123 | >Note: This initializer is only available on macOS 11.0 and higher. 124 | 125 | ```swift 126 | let separatorInset = NSEdgeInsets(top: 0, left: 24, bottom: 0, right: 0) 127 | 128 | OutlineView( 129 | data, 130 | selection: $selection, 131 | children: \.children, 132 | separatorInsets: { item in separatorInset }) { item in 133 | NSTextField(string: item.description) 134 | } 135 | ``` 136 | 137 | #### Row separator color 138 | 139 | You can customize the color of the row separators of the `OutlineView`. The default color is `NSColor.separatorColor`. 140 | 141 | ```swift 142 | OutlineView(data, selection: $selection, children: \.children) { item in 143 | NSTextField(string: item.description) 144 | } 145 | .rowSeparator(.visible) 146 | .rowSeparatorColor(.red) 147 | ``` 148 | 149 | ### Drag & Drop 150 | 151 | #### Dragging From `OutlineView` 152 | 153 | Add the `dragDataSource` modifier to the `OutlineView` to allow dragging rows from the `OutlineView`. The `dragDataSource` takes a closure that translates a data element into an optional `NSPasteboardItem`, with a `nil` value meaning the row can't be dragged). 154 | 155 | ```swift 156 | extension NSPasteboard.PasteboardType { 157 | static var myPasteboardType: Self { 158 | PasteboardType("MySpecialPasteboardIdentifier") 159 | } 160 | } 161 | 162 | outlineView 163 | .dragDataSource { item in 164 | let pasteboardItem = NSPasteboardItem() 165 | pasteboardItem.setData(item.dataRepresentation, forType: .myPasteboardType) 166 | return pasteboardItem 167 | } 168 | ``` 169 | 170 | #### Dropping into `OutlineView` 171 | 172 | Drag events on the `OutlineView`, either from the `dragDataSource` modifier or from outside the `OutlineView`, can be handled by adding the `onDrop(of:receiver:)` modifier. This modifier takes a list of supported `NSPasteboard.PasteboardType`s and a receiver instance conforming to the `DropReceiver` protocol. `DropReceiver` implements functions to validate a drop operation, read items from the dragging pasteboard, and update the data source when a drop is successful. 173 | 174 | ```swift 175 | outlineView 176 | .onDrop(of: [.myPasteboardType, .fileUrl], receiver: MyDropReceiver()) 177 | 178 | class MyDropReceiver: DropReceiver { 179 | func readPasteboard(item: NSPasteboardItem) -> DraggedItem? { 180 | guard let pasteboardType = item.availableType(from: pasteboardTypes) else { return nil } 181 | 182 | switch pasteboardType { 183 | case .myPasteboardType: 184 | if let draggedData = item.data(forType: .myPasteboardType) { 185 | let draggedFileItem = /* instance of OutlineView.Data.Element from draggedData */ 186 | return (draggedFileItem, .myPasteboardType) 187 | } else { 188 | return nil 189 | } 190 | case .fileUrl: 191 | if let draggedUrlString = item.string(forType: .fileUrl), 192 | draggedUrl = URL(string: draggedUrlString) 193 | { 194 | let newFileItem = /* instance of OutlineView.Data.Element from draggedUrl */ 195 | return (newFileItem, .fileUrl) 196 | } else { 197 | return nil 198 | } 199 | default: 200 | return nil 201 | } 202 | } 203 | 204 | func validateDrop(target: DropTarget) -> ValidationResult { 205 | let draggedItems = target.draggedItems 206 | 207 | if draggedItems[0].type == .myPasteboardType { 208 | return .move 209 | } else if draggedItems[0].type == .fileUrl { 210 | return .copy 211 | } else { 212 | return .deny 213 | } 214 | } 215 | 216 | func acceptDrop(target: DropTarget) -> Bool { 217 | // update data source to reflect that drop was successful or not 218 | return dropWasSuccessful 219 | } 220 | } 221 | ``` 222 | 223 | For more details on the various types needed in `onDrop`, see `OutlineViewDragAndDrop.swift`, and the sample app `OutlineViewDraggingExample`. 224 | 225 | ## Why use `OutlineView` instead of the native `List` with children? 226 | 227 | `OutlineView` is meant to serve as a stopgap solution to a few of the quirks of `OutlineGroup`s in a `List` or `List` with children on macOS. 228 | 229 | - The current implementation of updates on a list with `OutlineGroup`s is miscalculated, which leads to incorrect cell updates on the UI and crashes due to accessing invalid indices on the internal model. This bug makes the `OutlineGroup` unusable on macOS unless you are working with static content. 230 | - It is easier to expose more of the built-in features of an `NSOutlineView` as we have full control over the code, which enables bringing over additional features in the future like support for multiple columns. 231 | - Unlike SwiftUI's native `OutlineGroup` or `List` with children, `OutlineView` supports macOS 10.15 Catalina. 232 | - `OutlineView` supports row animations for updates by default. 233 | 234 | ## Caveats 235 | 236 | `OutlineView` is implemented using the public API for SwiftUI, leading to some limitations that are hard to workaround. 237 | 238 | - The content of the cells has to be represented as an `NSView`. This is required as `NSOutlineView` has internal methods for automatically changing the selected cell's text color. A SwiftUI `Text` is not accessible from AppKit, and therefore, any SwiftUI `Text` views will not be able to adopt the system behavior for the highlighted cell's text color. Providing an `NSView` with `NSTextField`s for displaying text allows us to work around that limitation. 239 | - Automatic height `NSOutlineView`s still seems to require an initial cell height to be provided. This in itself is not a problem, but the default `fittingSize` of an `NSView` with the correct constraints around a multiline `NSTextField` is miscalculated. The `NSTextField`'s width does not seem to be bounded when the fitting size is calculated (even if a correct max-width constraint was provided to the `NSView`). So, if you have a variable height `NSView`, you have to make sure that the `fittingSize` is computed appropriately. (Setting the `NSTextField.preferredMaxLayoutWidth` to the expected width for fitting size calculations should be sufficient.) 240 | -------------------------------------------------------------------------------- /Sources/OutlineView/AdjustableSeparatorRowView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import ObjectiveC 3 | 4 | /// An NSTableRowView with an adjustable separator line. 5 | @available(macOS 11.0, *) 6 | final class AdjustableSeparatorRowView: NSTableRowView { 7 | var separatorInsets: NSEdgeInsets? 8 | 9 | public override init(frame frameRect: NSRect) { 10 | Self.setupSwizzling 11 | super.init(frame: frameRect) 12 | } 13 | 14 | required init?(coder: NSCoder) { 15 | Self.setupSwizzling 16 | super.init(coder: coder) 17 | } 18 | 19 | /// Our implementation of the private `_separatorRect` method. 20 | /// Computes the frame of the `_separatorView`. 21 | @objc 22 | func separatorRect() -> CGRect { 23 | // Make sure we only override the behavior for this class. 24 | guard type(of: self) == AdjustableSeparatorRowView.self else { 25 | return Self.originalSeparatorRect?(self) ?? .zero 26 | } 27 | 28 | // Only override the default behavior if the 29 | // separator insets are not available. 30 | guard let separatorInsets = separatorInsets else { 31 | // Get the frame from the original method. 32 | return Self.originalSeparatorRect?(self) ?? .zero 33 | } 34 | 35 | guard self.numberOfColumns > 0, 36 | let viewRect = (self.view(atColumn: 0) as? NSView)?.frame 37 | else { return .zero } 38 | 39 | // One point thick separator of the width of the first (and only) column. 40 | let separatorRect = NSRect( 41 | x: viewRect.origin.x, 42 | y: max(0, viewRect.height - 1), 43 | width: viewRect.width, 44 | height: 1) 45 | 46 | // Inset the separator frame by the separatorInsets. 47 | return CGRect( 48 | x: separatorRect.origin.x + separatorInsets.left, 49 | y: separatorRect.origin.y + separatorInsets.top, 50 | width: separatorRect.width - separatorInsets.left - separatorInsets.right, 51 | height: separatorRect.height - separatorInsets.top - separatorInsets.bottom) 52 | } 53 | 54 | /// Stores the original implementation of `_separatorRect` if successfully swizzled. 55 | static var originalSeparatorRect: ((NSTableRowView) -> CGRect)? 56 | 57 | /// Swizzle the private `_separatorRect` defined on NSTableRowView. 58 | /// Should be executed early in the life-cycle of `AdjustableSeparatorRowView`. 59 | static let setupSwizzling: Void = { 60 | // Selector for _separatorRect. 61 | let privateSeparatorRectSelector = Selector(unmangle("^rdo`q`snqQdbs")) 62 | guard 63 | let originalMethod = class_getInstanceMethod( 64 | AdjustableSeparatorRowView.self, 65 | privateSeparatorRectSelector), 66 | let newMethod = class_getInstanceMethod( 67 | AdjustableSeparatorRowView.self, 68 | #selector(separatorRect)) 69 | else { return } 70 | 71 | // Replace the original implementation with our implementation. 72 | let originalImplementation = method_setImplementation( 73 | originalMethod, 74 | method_getImplementation(newMethod)) 75 | 76 | // Store the original implementation for later use. 77 | originalSeparatorRect = { instance in 78 | let privateSeparatorRect = unsafeBitCast( 79 | originalImplementation, 80 | to: (@convention(c) (Any?, Selector?) -> CGRect).self) 81 | return privateSeparatorRect(instance, privateSeparatorRectSelector) 82 | } 83 | }() 84 | } 85 | -------------------------------------------------------------------------------- /Sources/OutlineView/NSEdgeInsets+Equatable.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSEdgeInsets: Equatable { 4 | public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { 5 | NSEdgeInsetsEqual(lhs, rhs) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/OutlineView/NSEdgeInsets+Zero.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSEdgeInsets { 4 | static var zero: NSEdgeInsets { 5 | NSEdgeInsetsZero 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/OutlineView/Notifications.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSOutlineView { 4 | 5 | /// Converts a notification from any of `NSOutlineView`'s item notifiers 6 | /// into a tuple of the `NSOutlineView` and the item that was notified about. 7 | /// 8 | /// Notifications this works for: 9 | /// - `itemDidCollapseNotification` 10 | /// - `itemDidExpandNotification` 11 | /// - `itemWillCollapseNotification` 12 | /// - `itemWillExpandNotification` 13 | /// 14 | /// If the Notification is in an incorrect format for any of the above notifications, 15 | /// this function returns `nil`. 16 | static func expansionNotificationInfo(_ note: Notification) -> (outlineView: NSOutlineView, object: Any)? { 17 | guard let outlineView = note.object as? NSOutlineView, 18 | let object = note.userInfo?["NSObject"] 19 | else { return nil } 20 | 21 | return (outlineView, object) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Cocoa 3 | 4 | enum ChildSource { 5 | case keyPath(KeyPath) 6 | case provider((Data.Element) -> Data?) 7 | 8 | func children(for element: Data.Element) -> Data? { 9 | switch self { 10 | case .keyPath(let keyPath): 11 | return element[keyPath: keyPath] 12 | case .provider(let provider): 13 | return provider(element) 14 | } 15 | } 16 | } 17 | 18 | @available(macOS 10.15, *) 19 | public struct OutlineView: NSViewControllerRepresentable 20 | where Drop.DataElement == Data.Element { 21 | public typealias NSViewControllerType = OutlineViewController 22 | 23 | let data: Data 24 | let childSource: ChildSource 25 | @Binding var selection: Data.Element? 26 | var content: (Data.Element) -> NSView 27 | var separatorInsets: ((Data.Element) -> NSEdgeInsets)? 28 | 29 | /// Outline view style is unavailable on macOS 10.15 and below. 30 | /// Stored as `Any` to make the property available on all platforms. 31 | private var _styleStorage: Any? 32 | 33 | @available(macOS 11.0, *) 34 | var style: NSOutlineView.Style { 35 | get { 36 | _styleStorage 37 | .flatMap { $0 as? NSOutlineView.Style } 38 | ?? .automatic 39 | } 40 | set { _styleStorage = newValue } 41 | } 42 | 43 | var indentation: CGFloat = 13.0 44 | var separatorVisibility: SeparatorVisibility 45 | var separatorColor: NSColor = .separatorColor 46 | 47 | var dragDataSource: DragSourceWriter? 48 | var dropReceiver: Drop? = nil 49 | var acceptedDropTypes: [NSPasteboard.PasteboardType]? = nil 50 | 51 | // MARK: NSViewControllerRepresentable 52 | 53 | public func makeNSViewController(context: Context) -> OutlineViewController { 54 | let controller = OutlineViewController( 55 | data: data, 56 | childrenSource: childSource, 57 | content: content, 58 | selectionChanged: { selection = $0 }, 59 | separatorInsets: separatorInsets) 60 | controller.setIndentation(to: indentation) 61 | if #available(macOS 11.0, *) { 62 | controller.setStyle(to: style) 63 | } 64 | return controller 65 | } 66 | 67 | public func updateNSViewController( 68 | _ outlineController: OutlineViewController, 69 | context: Context 70 | ) { 71 | outlineController.updateData(newValue: data) 72 | outlineController.changeSelectedItem(to: selection) 73 | outlineController.setRowSeparator(visibility: separatorVisibility) 74 | outlineController.setRowSeparator(color: separatorColor) 75 | outlineController.setDragSourceWriter(dragDataSource) 76 | outlineController.setDropReceiver(dropReceiver) 77 | outlineController.setAcceptedDragTypes(acceptedDropTypes) 78 | } 79 | } 80 | 81 | // MARK: - Modifiers 82 | 83 | @available(macOS 10.15, *) 84 | public extension OutlineView { 85 | /// Sets the style for the `OutlineView`. 86 | @available(macOS 11.0, *) 87 | func outlineViewStyle(_ style: NSOutlineView.Style) -> Self { 88 | var mutableSelf = self 89 | mutableSelf.style = style 90 | return mutableSelf 91 | } 92 | 93 | /// Sets the width of the indentation per level for the `OutlineView`. 94 | func outlineViewIndentation(_ width: CGFloat) -> Self { 95 | var mutableSelf = self 96 | mutableSelf.indentation = width 97 | return mutableSelf 98 | } 99 | 100 | /// Sets the visibility of the separator between rows of the `OutlineView`. 101 | func rowSeparator(_ visibility: SeparatorVisibility) -> Self { 102 | var mutableSelf = self 103 | mutableSelf.separatorVisibility = visibility 104 | return mutableSelf 105 | } 106 | 107 | /// Sets the color of the separator between rows of this `OutlineView`. 108 | /// The default color for the separator is `NSColor.separatorColor`. 109 | func rowSeparatorColor(_ color: NSColor) -> Self { 110 | var mutableSelf = self 111 | mutableSelf.separatorColor = color 112 | return mutableSelf 113 | } 114 | 115 | /// Adds a drop receiver to the `OutlineView`, allowing it to react to drag 116 | /// and drop operations. 117 | /// 118 | /// - Parameters 119 | /// - acceptedTypes: An array of `PasteboardType`s that the `DropReceiver` is able to read. 120 | /// - receiver: A delegate conforming to `DropReceiver` that handles receiving a 121 | /// drag-and-drop operation onto the `OutlineView`. 122 | func onDrop(of acceptedTypes: [NSPasteboard.PasteboardType], receiver: Drop) -> Self { 123 | var mutableSelf = self 124 | mutableSelf.acceptedDropTypes = acceptedTypes 125 | mutableSelf.dropReceiver = receiver 126 | return mutableSelf 127 | } 128 | 129 | /// Enables dragging of rows from the `OutlineView` by setting the `DragSourceWriter` 130 | /// of the `OutlineView`. 131 | /// 132 | /// The simplest way to create the data for the pasteboard item is to initialize 133 | /// the `NSPasteboardItem` and then calling `setData(_:forType:)` or other types 134 | /// of `set` functions. 135 | /// 136 | /// - Parameter writer: A closure that takes the `Data.Element` from a given row of 137 | /// the `OutlineView`, and returns an optional `NSPasteboardItem` with data about the 138 | /// item to be dragged. If `nil` is returned, that row can not be dragged. 139 | func dragDataSource(_ writer: @escaping DragSourceWriter) -> Self { 140 | var mutableSelf = self 141 | mutableSelf.dragDataSource = writer 142 | return mutableSelf 143 | } 144 | } 145 | 146 | // MARK: - Initializers for macOS 10.15 and higher. 147 | 148 | @available(macOS 10.15, *) 149 | public extension OutlineView { 150 | /// Creates an `OutlineView` from a collection of root data elements and 151 | /// a key path to its children. 152 | /// 153 | /// This initializer creates an instance that uniquely identifies views 154 | /// across updates based on the identity of the underlying data element. 155 | /// 156 | /// All generated rows begin in the collapsed state. 157 | /// 158 | /// Make sure that the identifier of a data element only changes if you 159 | /// mean to replace that element with a new element, one with a new 160 | /// identity. If the ID of an element changes, then the content view 161 | /// generated from that element will lose any current state and animations. 162 | /// 163 | /// - NOTE: All elements in data should be uniquely identified. Data with 164 | /// elements that have a repeated identity are not supported. 165 | /// 166 | /// - Parameters: 167 | /// - data: A collection of tree-structured, identified data. 168 | /// - children: A key path to a property whose non-`nil` value gives the 169 | /// children of `data`. A non-`nil` but empty value denotes an element 170 | /// capable of having children that's currently childless, such as an 171 | /// empty directory in a file system. On the other hand, if the property 172 | /// at the key path is `nil`, then the outline view treats `data` as a 173 | /// leaf in the tree, like a regular file in a file system. 174 | /// - selection: A binding to a selected value. 175 | /// - content: A closure that produces an `NSView` based on an 176 | /// element in `data`. An `NSTableCellView` subclass is preferred. 177 | /// The `NSView` should return the correct `fittingSize` 178 | /// as it is used to determine the height of the cell. 179 | init( 180 | _ data: Data, 181 | children: KeyPath, 182 | selection: Binding, 183 | content: @escaping (Data.Element) -> NSView 184 | ) { 185 | self.data = data 186 | self.childSource = .keyPath(children) 187 | self._selection = selection 188 | self.separatorVisibility = .hidden 189 | self.content = content 190 | } 191 | 192 | /// Creates an `OutlineView` from a collection of root data elements and 193 | /// a closure that provides children to each element. 194 | /// 195 | /// This initializer creates an instance that uniquely identifies views 196 | /// across updates based on the identity of the underlying data element. 197 | /// 198 | /// All generated rows begin in the collapsed state. 199 | /// 200 | /// Make sure that the identifier of a data element only changes if you 201 | /// mean to replace that element with a new element, one with a new 202 | /// identity. If the ID of an element changes, then the content view 203 | /// generated from that element will lose any current state and animations. 204 | /// 205 | /// - NOTE: All elements in data should be uniquely identified. Data with 206 | /// elements that have a repeated identity are not supported. 207 | /// 208 | /// - Parameters: 209 | /// - data: A collection of tree-structured, identified data. 210 | /// - selection: A binding to a selected value. 211 | /// - children: A closure whose non-`nil` return value gives the 212 | /// children of `data`. A non-`nil` but empty value denotes an element 213 | /// capable of having children that's currently childless, such as an 214 | /// empty directory in a file system. On the other hand, if the value 215 | /// from the closure is `nil`, then the outline view treats `data` as a 216 | /// leaf in the tree, like a regular file in a file system. 217 | /// - content: A closure that produces an `NSView` based on an 218 | /// element in `data`. An `NSTableCellView` subclass is preferred. 219 | /// The `NSView` should return the correct `fittingSize` 220 | /// as it is used to determine the height of the cell. 221 | init( 222 | _ data: Data, 223 | selection: Binding, 224 | children: @escaping (Data.Element) -> Data?, 225 | content: @escaping (Data.Element) -> NSView 226 | ) { 227 | self.data = data 228 | self._selection = selection 229 | self.childSource = .provider(children) 230 | self.separatorVisibility = .hidden 231 | self.content = content 232 | } 233 | } 234 | 235 | // MARK: Initializers for macOS 10.15 and higher with NoDropReceiver. 236 | 237 | @available(macOS 10.15, *) 238 | public extension OutlineView where Drop == NoDropReceiver { 239 | /// Creates an `OutlineView` from a collection of root data elements and 240 | /// a key path to its children. 241 | /// 242 | /// This initializer creates an instance that uniquely identifies views 243 | /// across updates based on the identity of the underlying data element. 244 | /// 245 | /// All generated rows begin in the collapsed state. 246 | /// 247 | /// Make sure that the identifier of a data element only changes if you 248 | /// mean to replace that element with a new element, one with a new 249 | /// identity. If the ID of an element changes, then the content view 250 | /// generated from that element will lose any current state and animations. 251 | /// 252 | /// - NOTE: All elements in data should be uniquely identified. Data with 253 | /// elements that have a repeated identity are not supported. 254 | /// 255 | /// - Parameters: 256 | /// - data: A collection of tree-structured, identified data. 257 | /// - children: A key path to a property whose non-`nil` value gives the 258 | /// children of `data`. A non-`nil` but empty value denotes an element 259 | /// capable of having children that's currently childless, such as an 260 | /// empty directory in a file system. On the other hand, if the property 261 | /// at the key path is `nil`, then the outline view treats `data` as a 262 | /// leaf in the tree, like a regular file in a file system. 263 | /// - selection: A binding to a selected value. 264 | /// - content: A closure that produces an `NSView` based on an 265 | /// element in `data`. An `NSTableCellView` subclass is preferred. 266 | /// The `NSView` should return the correct `fittingSize` 267 | /// as it is used to determine the height of the cell. 268 | init( 269 | _ data: Data, 270 | children: KeyPath, 271 | selection: Binding, 272 | content: @escaping (Data.Element) -> NSView 273 | ) { 274 | self.data = data 275 | self.childSource = .keyPath(children) 276 | self._selection = selection 277 | self.separatorVisibility = .hidden 278 | self.content = content 279 | } 280 | 281 | /// Creates an `OutlineView` from a collection of root data elements and 282 | /// a closure that provides children to each element. 283 | /// 284 | /// This initializer creates an instance that uniquely identifies views 285 | /// across updates based on the identity of the underlying data element. 286 | /// 287 | /// All generated rows begin in the collapsed state. 288 | /// 289 | /// Make sure that the identifier of a data element only changes if you 290 | /// mean to replace that element with a new element, one with a new 291 | /// identity. If the ID of an element changes, then the content view 292 | /// generated from that element will lose any current state and animations. 293 | /// 294 | /// - NOTE: All elements in data should be uniquely identified. Data with 295 | /// elements that have a repeated identity are not supported. 296 | /// 297 | /// - Parameters: 298 | /// - data: A collection of tree-structured, identified data. 299 | /// - selection: A binding to a selected value. 300 | /// - children: A closure whose non-`nil` return value gives the 301 | /// children of `data`. A non-`nil` but empty value denotes an element 302 | /// capable of having children that's currently childless, such as an 303 | /// empty directory in a file system. On the other hand, if the value 304 | /// from the closure is `nil`, then the outline view treats `data` as a 305 | /// leaf in the tree, like a regular file in a file system. 306 | /// - content: A closure that produces an `NSView` based on an 307 | /// element in `data`. An `NSTableCellView` subclass is preferred. 308 | /// The `NSView` should return the correct `fittingSize` 309 | /// as it is used to determine the height of the cell. 310 | init( 311 | _ data: Data, 312 | selection: Binding, 313 | children: @escaping (Data.Element) -> Data?, 314 | content: @escaping (Data.Element) -> NSView 315 | ) { 316 | self.data = data 317 | self._selection = selection 318 | self.childSource = .provider(children) 319 | self.separatorVisibility = .hidden 320 | self.content = content 321 | } 322 | } 323 | 324 | // MARK: Initializers for macOS 11 and higher. 325 | 326 | @available(macOS 11.0, *) 327 | public extension OutlineView { 328 | /// Creates an `OutlineView` from a collection of root data elements and 329 | /// a key path to its children. 330 | /// 331 | /// This initializer creates an instance that uniquely identifies views 332 | /// across updates based on the identity of the underlying data element. 333 | /// 334 | /// All generated rows begin in the collapsed state. 335 | /// 336 | /// Make sure that the identifier of a data element only changes if you 337 | /// mean to replace that element with a new element, one with a new 338 | /// identity. If the ID of an element changes, then the content view 339 | /// generated from that element will lose any current state and animations. 340 | /// 341 | /// - NOTE: All elements in data should be uniquely identified. Data with 342 | /// elements that have a repeated identity are not supported. 343 | /// 344 | /// - Parameters: 345 | /// - data: A collection of tree-structured, identified data. 346 | /// - children: A key path to a property whose non-`nil` value gives the 347 | /// children of `data`. A non-`nil` but empty value denotes an element 348 | /// capable of having children that's currently childless, such as an 349 | /// empty directory in a file system. On the other hand, if the property 350 | /// at the key path is `nil`, then the outline view treats `data` as a 351 | /// leaf in the tree, like a regular file in a file system. 352 | /// - selection: A binding to a selected value. 353 | /// - separatorInsets: An optional closure that produces row separator lines 354 | /// with the given insets for each item in the outline view. If this closure 355 | /// is not provided (the default), separators are hidden. 356 | /// - content: A closure that produces an `NSView` based on an 357 | /// element in `data`. An `NSTableCellView` subclass is preferred. 358 | /// The `NSView` should return the correct `fittingSize` 359 | /// as it is used to determine the height of the cell. 360 | @available(macOS 11.0, *) 361 | init( 362 | _ data: Data, 363 | children: KeyPath, 364 | selection: Binding, 365 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, 366 | content: @escaping (Data.Element) -> NSView 367 | ) { 368 | self.data = data 369 | self.childSource = .keyPath(children) 370 | self._selection = selection 371 | self.separatorInsets = separatorInsets 372 | self.separatorVisibility = separatorInsets == nil ? .hidden : .visible 373 | self.content = content 374 | } 375 | 376 | /// Creates an `OutlineView` from a collection of root data elements and 377 | /// a closure that provides children to each element. 378 | /// 379 | /// This initializer creates an instance that uniquely identifies views 380 | /// across updates based on the identity of the underlying data element. 381 | /// 382 | /// All generated rows begin in the collapsed state. 383 | /// 384 | /// Make sure that the identifier of a data element only changes if you 385 | /// mean to replace that element with a new element, one with a new 386 | /// identity. If the ID of an element changes, then the content view 387 | /// generated from that element will lose any current state and animations. 388 | /// 389 | /// - NOTE: All elements in data should be uniquely identified. Data with 390 | /// elements that have a repeated identity are not supported. 391 | /// 392 | /// - Parameters: 393 | /// - data: A collection of tree-structured, identified data. 394 | /// - selection: A binding to a selected value. 395 | /// - children: A closure whose non-`nil` return value gives the 396 | /// children of `data`. A non-`nil` but empty value denotes an element 397 | /// capable of having children that's currently childless, such as an 398 | /// empty directory in a file system. On the other hand, if the value 399 | /// from the closure is `nil`, then the outline view treats `data` as a 400 | /// leaf in the tree, like a regular file in a file system. 401 | /// - separatorInsets: An optional closure that produces row separator lines 402 | /// with the given insets for each item in the outline view. If this closure 403 | /// is not provided (the default), separators are hidden. 404 | /// - content: A closure that produces an `NSView` based on an 405 | /// element in `data`. An `NSTableCellView` subclass is preferred. 406 | /// The `NSView` should return the correct `fittingSize` 407 | /// as it is used to determine the height of the cell. 408 | @available(macOS 11.0, *) 409 | init( 410 | _ data: Data, 411 | selection: Binding, 412 | children: @escaping (Data.Element) -> Data?, 413 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, 414 | content: @escaping (Data.Element) -> NSView 415 | ) { 416 | self.data = data 417 | self._selection = selection 418 | self.childSource = .provider(children) 419 | self.separatorInsets = separatorInsets 420 | self.separatorVisibility = separatorInsets == nil ? .hidden : .visible 421 | self.content = content 422 | } 423 | } 424 | 425 | // MARK: Initializers for macOS 11 and higher with NoDropReceiver. 426 | 427 | @available(macOS 11.0, *) 428 | public extension OutlineView where Drop == NoDropReceiver { 429 | /// Creates an `OutlineView` from a collection of root data elements and 430 | /// a key path to its children. 431 | /// 432 | /// This initializer creates an instance that uniquely identifies views 433 | /// across updates based on the identity of the underlying data element. 434 | /// 435 | /// All generated rows begin in the collapsed state. 436 | /// 437 | /// Make sure that the identifier of a data element only changes if you 438 | /// mean to replace that element with a new element, one with a new 439 | /// identity. If the ID of an element changes, then the content view 440 | /// generated from that element will lose any current state and animations. 441 | /// 442 | /// - NOTE: All elements in data should be uniquely identified. Data with 443 | /// elements that have a repeated identity are not supported. 444 | /// 445 | /// - Parameters: 446 | /// - data: A collection of tree-structured, identified data. 447 | /// - children: A key path to a property whose non-`nil` value gives the 448 | /// children of `data`. A non-`nil` but empty value denotes an element 449 | /// capable of having children that's currently childless, such as an 450 | /// empty directory in a file system. On the other hand, if the property 451 | /// at the key path is `nil`, then the outline view treats `data` as a 452 | /// leaf in the tree, like a regular file in a file system. 453 | /// - selection: A binding to a selected value. 454 | /// - separatorInsets: An optional closure that produces row separator lines 455 | /// with the given insets for each item in the outline view. If this closure 456 | /// is not provided (the default), separators are hidden. 457 | /// - content: A closure that produces an `NSView` based on an 458 | /// element in `data`. An `NSTableCellView` subclass is preferred. 459 | /// The `NSView` should return the correct `fittingSize` 460 | /// as it is used to determine the height of the cell. 461 | init( 462 | _ data: Data, 463 | children: KeyPath, 464 | selection: Binding, 465 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, 466 | content: @escaping (Data.Element) -> NSView 467 | ) { 468 | self.data = data 469 | self.childSource = .keyPath(children) 470 | self._selection = selection 471 | self.separatorInsets = separatorInsets 472 | self.separatorVisibility = separatorInsets == nil ? .hidden : .visible 473 | self.content = content 474 | } 475 | 476 | /// Creates an `OutlineView` from a collection of root data elements and 477 | /// a closure that provides children to each element. 478 | /// 479 | /// This initializer creates an instance that uniquely identifies views 480 | /// across updates based on the identity of the underlying data element. 481 | /// 482 | /// All generated rows begin in the collapsed state. 483 | /// 484 | /// Make sure that the identifier of a data element only changes if you 485 | /// mean to replace that element with a new element, one with a new 486 | /// identity. If the ID of an element changes, then the content view 487 | /// generated from that element will lose any current state and animations. 488 | /// 489 | /// - NOTE: All elements in data should be uniquely identified. Data with 490 | /// elements that have a repeated identity are not supported. 491 | /// 492 | /// - Parameters: 493 | /// - data: A collection of tree-structured, identified data. 494 | /// - selection: A binding to a selected value. 495 | /// - children: A closure whose non-`nil` return value gives the 496 | /// children of `data`. A non-`nil` but empty value denotes an element 497 | /// capable of having children that's currently childless, such as an 498 | /// empty directory in a file system. On the other hand, if the value 499 | /// from the closure is `nil`, then the outline view treats `data` as a 500 | /// leaf in the tree, like a regular file in a file system. 501 | /// - separatorInsets: An optional closure that produces row separator lines 502 | /// with the given insets for each item in the outline view. If this closure 503 | /// is not provided (the default), separators are hidden. 504 | /// - content: A closure that produces an `NSView` based on an 505 | /// element in `data`. An `NSTableCellView` subclass is preferred. 506 | /// The `NSView` should return the correct `fittingSize` 507 | /// as it is used to determine the height of the cell. 508 | init( 509 | _ data: Data, 510 | selection: Binding, 511 | children: @escaping (Data.Element) -> Data?, 512 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, 513 | content: @escaping (Data.Element) -> NSView 514 | ) { 515 | self.data = data 516 | self._selection = selection 517 | self.childSource = .provider(children) 518 | self.separatorInsets = separatorInsets 519 | self.separatorVisibility = separatorInsets == nil ? .hidden : .visible 520 | self.content = content 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | @available(macOS 10.15, *) 4 | public class OutlineViewController: NSViewController 5 | where Drop.DataElement == Data.Element { 6 | let outlineView = NSOutlineView() 7 | let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 400, height: 400)) 8 | 9 | let dataSource: OutlineViewDataSource 10 | let delegate: OutlineViewDelegate 11 | let updater = OutlineViewUpdater() 12 | 13 | let childrenSource: ChildSource 14 | 15 | init( 16 | data: Data, 17 | childrenSource: ChildSource, 18 | content: @escaping (Data.Element) -> NSView, 19 | selectionChanged: @escaping (Data.Element?) -> Void, 20 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? 21 | ) { 22 | scrollView.documentView = outlineView 23 | scrollView.hasVerticalScroller = true 24 | scrollView.hasHorizontalRuler = true 25 | scrollView.drawsBackground = false 26 | 27 | outlineView.autoresizesOutlineColumn = false 28 | outlineView.headerView = nil 29 | outlineView.usesAutomaticRowHeights = true 30 | outlineView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle 31 | 32 | let onlyColumn = NSTableColumn() 33 | onlyColumn.resizingMask = .autoresizingMask 34 | outlineView.addTableColumn(onlyColumn) 35 | 36 | dataSource = OutlineViewDataSource( 37 | items: data.map { OutlineViewItem(value: $0, children: childrenSource) }, 38 | childSource: childrenSource 39 | ) 40 | delegate = OutlineViewDelegate( 41 | content: content, 42 | selectionChanged: selectionChanged, 43 | separatorInsets: separatorInsets) 44 | outlineView.dataSource = dataSource 45 | outlineView.delegate = delegate 46 | 47 | self.childrenSource = childrenSource 48 | 49 | super.init(nibName: nil, bundle: nil) 50 | 51 | view.addSubview(scrollView) 52 | scrollView.translatesAutoresizingMaskIntoConstraints = false 53 | NSLayoutConstraint.activate([ 54 | scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 55 | scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 56 | scrollView.topAnchor.constraint(equalTo: view.topAnchor), 57 | scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 58 | ]) 59 | } 60 | 61 | required init?(coder: NSCoder) { 62 | return nil 63 | } 64 | 65 | public override func loadView() { 66 | view = NSView() 67 | } 68 | 69 | public override func viewWillAppear() { 70 | // Size the column to take the full width. This combined with 71 | // the uniform column autoresizing style allows the column to 72 | // adjust its width with a change in width of the outline view. 73 | outlineView.sizeLastColumnToFit() 74 | super.viewWillAppear() 75 | } 76 | } 77 | 78 | // MARK: - Performing updates 79 | @available(macOS 10.15, *) 80 | extension OutlineViewController { 81 | func updateData(newValue: Data) { 82 | let newState = newValue.map { OutlineViewItem(value: $0, children: childrenSource) } 83 | 84 | outlineView.beginUpdates() 85 | 86 | dataSource.items = newState 87 | updater.performUpdates( 88 | outlineView: outlineView, 89 | oldStateTree: dataSource.treeMap, 90 | newState: newState, 91 | parent: nil) 92 | 93 | outlineView.endUpdates() 94 | 95 | // After updates, dataSource must rebuild its idTree for future updates 96 | dataSource.rebuildIDTree(rootItems: newState, outlineView: outlineView) 97 | } 98 | 99 | func changeSelectedItem(to item: Data.Element?) { 100 | delegate.changeSelectedItem( 101 | to: item.map { OutlineViewItem(value: $0, children: childrenSource) }, 102 | in: outlineView) 103 | } 104 | 105 | @available(macOS 11.0, *) 106 | func setStyle(to style: NSOutlineView.Style) { 107 | outlineView.style = style 108 | } 109 | 110 | func setIndentation(to width: CGFloat) { 111 | outlineView.indentationPerLevel = width 112 | } 113 | 114 | func setRowSeparator(visibility: SeparatorVisibility) { 115 | switch visibility { 116 | case .hidden: 117 | outlineView.gridStyleMask = [] 118 | case .visible: 119 | outlineView.gridStyleMask = .solidHorizontalGridLineMask 120 | } 121 | } 122 | 123 | func setRowSeparator(color: NSColor) { 124 | guard color != outlineView.gridColor else { 125 | return 126 | } 127 | 128 | outlineView.gridColor = color 129 | outlineView.reloadData() 130 | } 131 | 132 | func setDragSourceWriter(_ writer: DragSourceWriter?) { 133 | dataSource.dragWriter = writer 134 | } 135 | 136 | func setDropReceiver(_ receiver: Drop?) { 137 | dataSource.dropReceiver = receiver 138 | } 139 | 140 | func setAcceptedDragTypes(_ acceptedTypes: [NSPasteboard.PasteboardType]?) { 141 | outlineView.unregisterDraggedTypes() 142 | if let acceptedTypes, 143 | !acceptedTypes.isEmpty 144 | { 145 | outlineView.registerForDraggedTypes(acceptedTypes) 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewDataSource.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | 4 | @available(macOS 10.15, *) 5 | class OutlineViewDataSource: NSObject, NSOutlineViewDataSource 6 | where Drop.DataElement == Data.Element { 7 | var items: [OutlineViewItem] 8 | var dropReceiver: Drop? 9 | var dragWriter: DragSourceWriter? 10 | let childrenSource: ChildSource 11 | 12 | var treeMap: TreeMap 13 | 14 | private var willExpandToken: AnyCancellable? 15 | private var didCollapseToken: AnyCancellable? 16 | 17 | init(items: [OutlineViewItem], childSource: ChildSource) { 18 | self.items = items 19 | self.childrenSource = childSource 20 | 21 | treeMap = TreeMap() 22 | for item in items { 23 | treeMap.addItem(item.value.id, isLeaf: item.children == nil, intoItem: nil, atIndex: nil) 24 | } 25 | 26 | super.init() 27 | 28 | // Listen for expand/collapse notifications in order to keep TreeMap up to date 29 | willExpandToken = NotificationCenter.default.publisher(for: NSOutlineView.itemWillExpandNotification) 30 | .compactMap { NSOutlineView.expansionNotificationInfo($0) } 31 | .sink { [weak self] in 32 | self?.receiveItemWillExpandNotification(outlineView: $0.outlineView, objectToExpand: $0.object) 33 | } 34 | didCollapseToken = NotificationCenter.default.publisher(for: NSOutlineView.itemDidCollapseNotification) 35 | .compactMap { NSOutlineView.expansionNotificationInfo($0) } 36 | .sink { [weak self] in 37 | self?.receiveItemDidCollapseNotification(outlineView: $0.outlineView, collapsedObject: $0.object) 38 | } 39 | } 40 | 41 | func rebuildIDTree(rootItems: [OutlineViewItem], outlineView: NSOutlineView) { 42 | treeMap = TreeMap(rootItems: rootItems, itemIsExpanded: { outlineView.isItemExpanded($0) }) 43 | } 44 | 45 | private func typedItem(_ item: Any) -> OutlineViewItem { 46 | item as! OutlineViewItem 47 | } 48 | 49 | // MARK: - Basic Data Source 50 | 51 | func outlineView( 52 | _ outlineView: NSOutlineView, 53 | numberOfChildrenOfItem item: Any? 54 | ) -> Int { 55 | if let item = item.map(typedItem) { 56 | return item.children?.count ?? 0 57 | } else { 58 | return items.count 59 | } 60 | } 61 | 62 | func outlineView( 63 | _ outlineView: NSOutlineView, 64 | isItemExpandable item: Any 65 | ) -> Bool { 66 | typedItem(item).children != nil 67 | } 68 | 69 | func outlineView( 70 | _ outlineView: NSOutlineView, 71 | child index: Int, 72 | ofItem item: Any? 73 | ) -> Any { 74 | if let item = item.map(typedItem) { 75 | // Should only be called if item has children. 76 | return item.children.unsafelyUnwrapped[index] 77 | } else { 78 | return items[index] 79 | } 80 | } 81 | 82 | // MARK: - Drag & Drop 83 | 84 | func outlineView( 85 | _ outlineView: NSOutlineView, 86 | pasteboardWriterForItem item: Any 87 | ) -> NSPasteboardWriting? { 88 | guard let writer = dragWriter, 89 | let writeData = writer(typedItem(item).value) 90 | else { return nil } 91 | 92 | return writeData 93 | } 94 | 95 | func outlineView( 96 | _ outlineView: NSOutlineView, 97 | validateDrop info: NSDraggingInfo, 98 | proposedItem item: Any?, 99 | proposedChildIndex index: Int 100 | ) -> NSDragOperation { 101 | guard let dropTarget = dropTargetData(in: outlineView, dragInfo: info, item: item, childIndex: index), 102 | let validationResult = dropReceiver?.validateDrop(target: dropTarget) 103 | else { return [] } 104 | 105 | switch validationResult { 106 | case .copy: 107 | return .copy 108 | case .move: 109 | return .move 110 | case .deny: 111 | return [] 112 | case let .copyRedirect(item, childIndex): 113 | let redirectTarget = item.map { OutlineViewItem(value: $0, children: childrenSource) } 114 | outlineView.setDropItem(redirectTarget, dropChildIndex: childIndex ?? NSOutlineViewDropOnItemIndex) 115 | return .copy 116 | case let .moveRedirect(item, childIndex): 117 | let redirectTarget = item.map { OutlineViewItem(value: $0, children: childrenSource) } 118 | outlineView.setDropItem(redirectTarget, dropChildIndex: childIndex ?? NSOutlineViewDropOnItemIndex) 119 | return .move 120 | } 121 | } 122 | 123 | func outlineView( 124 | _ outlineView: NSOutlineView, 125 | acceptDrop info: NSDraggingInfo, 126 | item: Any?, 127 | childIndex index: Int 128 | ) -> Bool { 129 | guard let dropTarget = dropTargetData(in: outlineView, dragInfo: info, item: item, childIndex: index), 130 | // Perform `acceptDrop(target:)` to get result of drop 131 | let dropIsSuccessful = dropReceiver?.acceptDrop(target: dropTarget) 132 | else { return false } 133 | 134 | return dropIsSuccessful 135 | } 136 | } 137 | 138 | // MARK: - Helper Functions 139 | 140 | @available(macOS 10.15, *) 141 | private extension OutlineViewDataSource { 142 | func receiveItemWillExpandNotification(outlineView: NSOutlineView, objectToExpand: Any) { 143 | guard let outlineDataSource = outlineView.dataSource, 144 | outlineDataSource.isEqual(self) 145 | else { return } 146 | 147 | let typedObjToExpand = typedItem(objectToExpand) 148 | if let childIDs = typedObjToExpand.children?.map({ ($0.id, $0.children == nil) }) { 149 | treeMap.expandItem(typedObjToExpand.value.id, children: childIDs) 150 | } 151 | } 152 | 153 | func receiveItemDidCollapseNotification(outlineView: NSOutlineView, collapsedObject: Any) { 154 | guard let outlineDataSource = outlineView.dataSource, 155 | outlineDataSource.isEqual(self) 156 | else { return } 157 | 158 | let typedObjThatCollapsed = typedItem(collapsedObject) 159 | treeMap.collapseItem(typedObjThatCollapsed.value.id) 160 | } 161 | 162 | func dropTargetData( 163 | in outlineView: NSOutlineView, 164 | dragInfo: NSDraggingInfo, 165 | item: Any?, 166 | childIndex: Int 167 | ) -> DropTarget? { 168 | guard let pasteboardItems = dragInfo.draggingPasteboard.pasteboardItems, 169 | !pasteboardItems.isEmpty, 170 | let dropReceiver 171 | else { return nil } 172 | 173 | let decodedItems = pasteboardItems.compactMap { 174 | dropReceiver.readPasteboard(item: $0) 175 | } 176 | 177 | let dropTarget = DropTarget( 178 | items: decodedItems, 179 | intoElement: item.map(typedItem)?.value, 180 | childIndex: childIndex == NSOutlineViewDropOnItemIndex ? nil : childIndex, 181 | isItemExpanded: { item in 182 | let typed = OutlineViewItem(value: item, children: self.childrenSource) 183 | return outlineView.isItemExpanded(typed) 184 | } 185 | ) 186 | return dropTarget 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | @available(macOS 10.15, *) 4 | class OutlineViewDelegate: NSObject, NSOutlineViewDelegate 5 | where Data.Element: Identifiable { 6 | let content: (Data.Element) -> NSView 7 | let selectionChanged: (Data.Element?) -> Void 8 | let separatorInsets: ((Data.Element) -> NSEdgeInsets)? 9 | var selectedItem: OutlineViewItem? 10 | 11 | func typedItem(_ item: Any) -> OutlineViewItem { 12 | item as! OutlineViewItem 13 | } 14 | 15 | init( 16 | content: @escaping (Data.Element) -> NSView, 17 | selectionChanged: @escaping (Data.Element?) -> Void, 18 | separatorInsets: ((Data.Element) -> NSEdgeInsets)? 19 | ) { 20 | self.content = content 21 | self.selectionChanged = selectionChanged 22 | self.separatorInsets = separatorInsets 23 | } 24 | 25 | func outlineView( 26 | _ outlineView: NSOutlineView, 27 | viewFor tableColumn: NSTableColumn?, 28 | item: Any 29 | ) -> NSView? { 30 | content(typedItem(item).value) 31 | } 32 | 33 | func outlineView( 34 | _ outlineView: NSOutlineView, 35 | rowViewForItem item: Any 36 | ) -> NSTableRowView? { 37 | if #available(macOS 11.0, *) { 38 | // Release any unused row views. 39 | releaseUnusedRowViews(from: outlineView) 40 | let rowView = AdjustableSeparatorRowView(frame: .zero) 41 | rowView.separatorInsets = separatorInsets?(typedItem(item).value) 42 | return rowView 43 | } else { 44 | return nil 45 | } 46 | } 47 | 48 | // There seems to be a memory leak on macOS 11 where row views returned 49 | // from `rowViewForItem` are never freed. This hack patches the leak. 50 | func releaseUnusedRowViews(from outlineView: NSOutlineView) { 51 | guard #available(macOS 11.0, *) else { return } 52 | 53 | // Equivalent to _rowData._rowViewPurgatory 54 | let purgatoryPath = unmangle("^qnvC`s`-^qnvUhdvOtqf`snqx") 55 | if let rowViewPurgatory = outlineView.value(forKeyPath: purgatoryPath) as? NSMutableSet { 56 | rowViewPurgatory 57 | .compactMap { $0 as? AdjustableSeparatorRowView } 58 | .forEach { 59 | $0.removeFromSuperview() 60 | rowViewPurgatory.remove($0) 61 | } 62 | } 63 | } 64 | 65 | func outlineView( 66 | _ outlineView: NSOutlineView, 67 | heightOfRowByItem item: Any 68 | ) -> CGFloat { 69 | // It appears that for outline views with automatic row heights, the 70 | // initial height of the row still needs to be provided. Not providing 71 | // a height for each cell would lead to the outline view defaulting to the 72 | // `outlineView.rowHeight` when inserted. The cell may resize to the correct 73 | // height if the outline view is reloaded. 74 | 75 | // I am not able to find a better way to compute the final width of the cell 76 | // other than hard-coding some of the constants. 77 | let columnHorizontalInset: CGFloat 78 | if #available(macOS 11.0, *) { 79 | if outlineView.effectiveStyle == .plain { 80 | columnHorizontalInset = 18 81 | } else { 82 | columnHorizontalInset = 9 83 | } 84 | } else { 85 | columnHorizontalInset = 9 86 | } 87 | 88 | let column = outlineView.tableColumns.first.unsafelyUnwrapped 89 | let indentInset = CGFloat(outlineView.level(forItem: item)) * outlineView.indentationPerLevel 90 | 91 | let width = column.width - indentInset - columnHorizontalInset 92 | 93 | // The view is provided by the user. And the width info is not provided 94 | // separately. It does not seem efficient to create a new cell to find 95 | // out the width of a cell. In practice I have not experienced any issues 96 | // with a moderate number of cells. 97 | let view = content(typedItem(item).value) 98 | view.widthAnchor.constraint(equalToConstant: width).isActive = true 99 | return view.fittingSize.height 100 | } 101 | 102 | func outlineViewItemDidExpand(_ notification: Notification) { 103 | let outlineView = notification.object as! NSOutlineView 104 | if outlineView.selectedRow == -1 { 105 | selectRow(for: selectedItem, in: outlineView) 106 | } 107 | } 108 | 109 | func outlineViewSelectionDidChange(_ notification: Notification) { 110 | let outlineView = notification.object as! NSOutlineView 111 | if outlineView.selectedRow != -1 { 112 | let newSelection = outlineView.item(atRow: outlineView.selectedRow).map(typedItem) 113 | if selectedItem?.id != newSelection?.id { 114 | selectedItem = newSelection 115 | selectionChanged(selectedItem?.value) 116 | } 117 | } 118 | } 119 | 120 | func selectRow( 121 | for item: OutlineViewItem?, 122 | in outlineView: NSOutlineView 123 | ) { 124 | // Returns -1 if row is not found. 125 | let index = outlineView.row(forItem: selectedItem) 126 | if index != -1 { 127 | outlineView.selectRowIndexes(IndexSet([index]), byExtendingSelection: false) 128 | } else { 129 | outlineView.deselectAll(nil) 130 | } 131 | } 132 | 133 | func changeSelectedItem( 134 | to item: OutlineViewItem?, 135 | in outlineView: NSOutlineView 136 | ) { 137 | guard selectedItem?.id != item?.id else { return } 138 | selectedItem = item 139 | selectRow(for: selectedItem, in: outlineView) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewDragAndDrop.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | /// A protocol for use with `OutlineView`, implemented by a delegate to interact 4 | /// with drop operations in an `OutlineView`. 5 | @available(macOS 10.15, *) 6 | public protocol DropReceiver { 7 | associatedtype DataElement: Identifiable 8 | 9 | /// Converts a `NSPasteboardItem` received by a drag-and-drop operation to the data 10 | /// element of an `OutlineView`. 11 | /// 12 | /// - Parameter item: The pasteboard item that is being dragged into the OutlineView 13 | /// 14 | /// - Returns: `nil` if the pasteboard item is not readable by this receiver, or 15 | /// a tuple with the decoded item and its associated PasteboardType, which must 16 | /// be included in `acceptedTypes`. 17 | func readPasteboard(item: NSPasteboardItem) -> DraggedItem? 18 | 19 | /// Defines the behavior of the `OutlineView` and the drag cursor when an item is 20 | /// being dragged. Called continuously as a dragged item is moved over the `OutlineView`. 21 | /// 22 | /// - Parameter target: A `DropTarget` describing where the dragged item 23 | /// is currently positioned. 24 | /// 25 | /// - Returns: A case of `ValidationResult`, which will either highlight the area 26 | /// of the `OutlineView` where the item will be dropped, or some other behavior. 27 | /// See `ValidationResult` for possible return values. 28 | func validateDrop(target: DropTarget) -> ValidationResult 29 | 30 | /// Handles updating the data source once an item is dropped into the `OutlineView`. 31 | /// Called once after the drop completes and `validateDrop(target:)` returns a case 32 | /// other than `deny`. 33 | /// 34 | /// - Parameter target: A `DropTarget` with instances of the dropped items and 35 | /// information about their position. 36 | /// 37 | /// - Returns: a boolean indicating that the drop was successful. 38 | func acceptDrop(target: DropTarget) -> Bool 39 | } 40 | 41 | @available(macOS 10.15, *) 42 | public enum NoDropReceiver: DropReceiver { 43 | public typealias DataElement = Element 44 | 45 | public func readPasteboard(item: NSPasteboardItem) -> DraggedItem? { 46 | fatalError() 47 | } 48 | 49 | public func validateDrop(target: DropTarget) -> ValidationResult { 50 | fatalError() 51 | } 52 | 53 | public func acceptDrop(target: DropTarget) -> Bool { 54 | fatalError() 55 | } 56 | } 57 | 58 | public typealias DragSourceWriter = (D) -> NSPasteboardItem? 59 | public typealias DraggedItem = (item: D, type: NSPasteboard.PasteboardType) 60 | 61 | /// An struct describing what items are being dragged into an `OutlineView`, and 62 | /// where in the data heirarchy they are being dropped. 63 | @available(macOS 10.15, *) 64 | public struct DropTarget { 65 | /// A non-empty array of `DraggedItem` tuples, each with the item 66 | /// that is being dragged, and the `NSPasteboard.PasteboardType` that 67 | /// generated the item from the dragging pasteboard. 68 | public var items: [DraggedItem] 69 | 70 | /// The `OutlineView` data element into which the target is dropping 71 | /// items. 72 | /// 73 | /// If `nil`, the items are intended to be dropped into the root of 74 | /// the data hierarchy. Otherwise, they are to be dropped into the given 75 | /// item's children array. 76 | public var intoElement: D? 77 | 78 | /// The index of the children array that the dragged items are to be 79 | /// dropped. 80 | /// 81 | /// If `nil`, assume that the items will be dropped at the default 82 | /// location for the children of `intoElement` (i.e. at the end, 83 | /// or into a default sorting order). Otherwise, the items should be 84 | /// inserted at the given index within the children. 85 | public var childIndex: Int? 86 | 87 | /// A closure that can be called to determine if the `OutlineView`'s 88 | /// representation of a given item is expanded. This may be used in 89 | /// `DropReceiver` functions that take a `DropTarget` as a parameter, 90 | /// in case the expanded state of the item affects the outcome of the 91 | /// function. 92 | public var isItemExpanded: ((D) -> Bool) 93 | } 94 | 95 | /// An enum describing the behavior of a dragged item as it moves over 96 | /// the `OutlineView`. 97 | public enum ValidationResult { 98 | /// Indicates that the dragged item will be copied to the indicated 99 | /// location. The given location will be highlighted, and the cursor 100 | /// will show a "+" icon. 101 | case copy 102 | 103 | /// Indicates that the dragged item will be moved to the indicated 104 | /// location. The given location will be highlighted. 105 | case move 106 | 107 | /// Indicates that the dragged item will not be moved. No location 108 | /// will be highlighted. 109 | case deny 110 | 111 | /// Indicates that the dragged item will be copied to a different 112 | /// location than it is currently hovering over. The cursor will 113 | /// show a "+" icon, and the highlighted location will be determined 114 | /// by `item` and `childIndex`. 115 | /// 116 | /// - Parameters: 117 | /// - item: The item that the dragged item will be added into as a child. 118 | /// If no item is given, the dragged item will be added to the root of 119 | /// the data structure. 120 | /// - childIndex: The index of the child array of `item` where the dragged 121 | /// item will be dropped. A nil value will cause `item` to be highlighted, 122 | /// while a non-nil value will cause a space between rows to be highlighted. 123 | case copyRedirect(item: D?, childIndex: Int?) 124 | 125 | /// Indicates that the dragged item will be moved to a different 126 | /// location than it is currently hovering over. The highlighted location 127 | /// will be determined by the bound values in this enum. 128 | /// 129 | /// - Parameters: 130 | /// - item: The item that the dragged item will be added into as a child. 131 | /// If no item is given, the dragged item will be added to the root of 132 | /// the data structure. 133 | /// - childIndex: The index of the child array of `item` where the dragged 134 | /// item will be dropped. A nil value will cause `item` to be highlighted, 135 | /// while a non-nil value will cause a space between rows to be highlighted. 136 | case moveRedirect(item: D?, childIndex: Int?) 137 | } 138 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewItem.swift: -------------------------------------------------------------------------------- 1 | /// A wrapper for holding the outline view data items. This wrapper exposes the `id` of the 2 | /// wrapped value for its conformance to `Equatable` and `Hashable`. `NSOutlineView` 3 | /// requires that Swift value types be "equal" to correctly find the stored item internally. 4 | /// `OutlineView` chooses to use the `Identifiable` protocol for identifying items, 5 | /// necessitating the use of a wrapper. 6 | /// 7 | /// Reference: AppKit Release Notes for macOS 10.14 - API Changes - `NSOutlineView` 8 | /// https://developer.apple.com/documentation/macos-release-notes/appkit-release-notes-for-macos-10_14 9 | @available(macOS 10.15, *) 10 | struct OutlineViewItem: Equatable, Hashable, Identifiable 11 | where Data.Element: Identifiable { 12 | var childSource: ChildSource 13 | var value: Data.Element 14 | 15 | var children: [OutlineViewItem]? { 16 | childSource.children(for: value)?.map { OutlineViewItem(value: $0, children: childSource) } 17 | } 18 | 19 | init(value: Data.Element, children: KeyPath) { 20 | self.init(value: value, children: .keyPath(children)) 21 | } 22 | 23 | init(value: Data.Element, children: @escaping (Data.Element) -> Data?) { 24 | self.init(value: value, children: .provider(children)) 25 | } 26 | 27 | init(value: Data.Element, children: ChildSource) { 28 | self.value = value 29 | self.childSource = children 30 | } 31 | 32 | var id: Data.Element.ID { 33 | value.id 34 | } 35 | 36 | static func == ( 37 | lhs: OutlineViewItem, 38 | rhs: OutlineViewItem 39 | ) -> Bool { 40 | lhs.value.id == rhs.value.id 41 | } 42 | 43 | func hash(into hasher: inout Hasher) { 44 | hasher.combine(value.id) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/OutlineView/OutlineViewUpdater.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | @available(macOS 10.15, *) 4 | struct OutlineViewUpdater 5 | where Data.Element: Identifiable { 6 | /// variable for testing purposes. When set to false (the default), 7 | /// `performUpdates` will escape its recursion for objects that are not 8 | /// expanded in the outlineView. 9 | var assumeOutlineIsExpanded = false 10 | 11 | /// Perform updates on the outline view based on the change in state. 12 | /// - NOTE: Calls to this method must be surrounded by 13 | /// `NSOutlineView.beginUpdates` and `NSOutlineView.endUpdates`. 14 | /// `OutlineViewDataSource.items` should be updated to the new state before calling this method. 15 | func performUpdates( 16 | outlineView: NSOutlineView, 17 | oldStateTree: TreeMap?, 18 | newState: [OutlineViewItem]?, 19 | parent: OutlineViewItem? 20 | ) { 21 | // Get states to compare: oldIDs and newIDs, as related to the given parent object 22 | let oldIDs: [Data.Element.ID]? 23 | if let oldStateTree { 24 | if let parent { 25 | oldIDs = oldStateTree.currentChildrenOfItem(parent.id) 26 | } else { 27 | oldIDs = oldStateTree.rootData 28 | } 29 | } else { 30 | oldIDs = nil 31 | } 32 | 33 | let newNonOptionalState = newState ?? [] 34 | 35 | guard oldIDs != nil || newState != nil else { 36 | // Early exit. No state to compare. 37 | return 38 | } 39 | 40 | let oldNonOptionalIDs = oldIDs ?? [] 41 | let newIDs = newNonOptionalState.map { $0.id } 42 | let diff = newIDs.difference(from: oldNonOptionalIDs) 43 | 44 | if !diff.isEmpty || oldIDs != newIDs { 45 | // Parent needs to be updated as the children have changed. 46 | // Children are not reloaded to allow animation. 47 | outlineView.reloadItem(parent, reloadChildren: false) 48 | } 49 | 50 | guard assumeOutlineIsExpanded || outlineView.isItemExpanded(parent) else { 51 | // Another early exit. If item isn't expanded, no need to compare its children. 52 | // They'll be updated when the item is later expanded. 53 | return 54 | } 55 | 56 | var oldUnchangedElements = newNonOptionalState 57 | .filter { oldNonOptionalIDs.contains($0.id) } 58 | .reduce(into: [:], { $0[$1.id] = $1 }) 59 | 60 | for change in diff { 61 | switch change { 62 | case .insert(offset: let offset, _, _): 63 | outlineView.insertItems( 64 | at: IndexSet([offset]), 65 | inParent: parent, 66 | withAnimation: .effectFade) 67 | 68 | case .remove(offset: let offset, element: let element, _): 69 | oldUnchangedElements[element] = nil 70 | outlineView.removeItems( 71 | at: IndexSet([offset]), 72 | inParent: parent, 73 | withAnimation: .effectFade) 74 | } 75 | } 76 | 77 | let newStateDict = newNonOptionalState.dictionaryFromIdentity() 78 | 79 | oldUnchangedElements 80 | .keys 81 | .map { newStateDict[$0].unsafelyUnwrapped } 82 | .map { (outlineView, oldStateTree, $0.children, $0) } 83 | .forEach(performUpdates) 84 | } 85 | } 86 | 87 | @available(macOS 10.15, *) 88 | fileprivate extension Sequence where Element: Identifiable { 89 | func dictionaryFromIdentity() -> [Element.ID: Element] { 90 | Dictionary(map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/OutlineView/StringMangling.swift: -------------------------------------------------------------------------------- 1 | /// Mangle a string by offsetting the numeric value of each character by `-1`. 2 | /// Useful for computing a mangled version of the objc private API strings to evade static detection of private API use. 3 | func mangle(_ string: String) -> String { 4 | String(string.utf16.map { $0 - 1 }.compactMap(UnicodeScalar.init).map(Character.init)) 5 | } 6 | 7 | /// Unmangle a string by offsetting the numeric value of each character by `+1`. 8 | /// Useful for unmangling a mangled version of the objc private API strings to evade static detection of private API use. 9 | func unmangle(_ string: String) -> String { 10 | String(string.utf16.map { $0 + 1 }.compactMap(UnicodeScalar.init).map(Character.init)) 11 | } 12 | -------------------------------------------------------------------------------- /Sources/OutlineView/TreeMap.swift: -------------------------------------------------------------------------------- 1 | 2 | /// An object representing the state of an OutlineView in which the content 3 | /// of the OutlineView is Identifiable. The TreeMap should be able to stay 4 | /// in sync with the OutlineView by reacting to data updates in the OutlineView. 5 | @available(macOS 10.15, *) 6 | class TreeMap { 7 | struct Node: Equatable { 8 | enum State: Equatable { 9 | case leaf 10 | case collapsed 11 | case expanded(children: [D]) 12 | } 13 | 14 | var parentID: D? 15 | var state: State 16 | 17 | init(parentID: D?, isLeaf: Bool) { 18 | self.parentID = parentID 19 | self.state = isLeaf ? .leaf : .collapsed 20 | } 21 | } 22 | 23 | private (set) var rootData: [D] = [] 24 | private var directory: [D : Node] = [:] 25 | 26 | var allItemIds: Set { 27 | Set(directory.keys) 28 | } 29 | 30 | init() {} 31 | 32 | init( 33 | rootItems: [OutlineViewItem], 34 | itemIsExpanded: ((OutlineViewItem) -> Bool) 35 | ) where Data.Element: Identifiable, Data.Element.ID == D { 36 | // Add root items first 37 | for item in rootItems { 38 | addItem(item.value.id, isLeaf: item.children == nil, intoItem: nil, atIndex: nil) 39 | } 40 | 41 | // Loop through all items, adding their children if they're expanded 42 | var checkingItems: [(parentID: D?, item: OutlineViewItem)] = rootItems.map { (nil, $0) } 43 | while let nextItem = checkingItems.popLast()?.item { 44 | if itemIsExpanded(nextItem), 45 | let children = nextItem.children 46 | { 47 | let expandingChildren = children.map { ($0.id, $0.children == nil) } 48 | expandItem(nextItem.id, children: expandingChildren) 49 | checkingItems.append(contentsOf: children.map({ (nextItem.id, $0) })) 50 | } 51 | } 52 | } 53 | 54 | /// Adds an item to the TreeMap 55 | /// 56 | /// - Parameters: 57 | /// - item: The ID of the item to add. 58 | /// - isLeaf: True if this item can have no children. 59 | /// - intoItem: The parent ID to insert this item into. If 60 | /// intoItem is nil, the new item is inserted at the root level. 61 | /// - atIndex: The index among other parent's children at which to 62 | /// insert this item. If no index is given, the item is added to the 63 | /// end of the array of children for the parent (or root if no 64 | /// parent is given). 65 | func addItem(_ item: D, isLeaf: Bool, intoItem: D?, atIndex: Int?) { 66 | // Add to parent or root 67 | if let intoItem, 68 | var fullIntoItem = directory[intoItem], 69 | case var .expanded(intoChildren) = fullIntoItem.state 70 | { 71 | // Add to children of selected item 72 | if let atIndex { 73 | // insert at index 74 | intoChildren.insert(item, at: atIndex) 75 | } else if atIndex == nil { 76 | // append to end 77 | intoChildren.append(item) 78 | } 79 | 80 | fullIntoItem.state = .expanded(children: intoChildren) 81 | directory[intoItem] = fullIntoItem 82 | } else { 83 | // Add to root data 84 | if let atIndex { 85 | // insert at index 86 | rootData.insert(item, at: atIndex) 87 | } else { 88 | // append to end 89 | rootData.append(item) 90 | } 91 | } 92 | 93 | // create new node 94 | let newNode = Node(parentID: intoItem, isLeaf: isLeaf) 95 | directory[item] = newNode 96 | } 97 | 98 | /// Marks the item with the given ID as expanded by adding its 99 | /// children to the TreeMap 100 | /// 101 | /// - Parameters: 102 | /// - item: the ID of the item to mark as expanded. 103 | /// - children: An array of objects representing the expanded 104 | /// item's children. Each child must have an ID and a boolean 105 | /// indicating whether it is a leaf or internal node of the tree. 106 | func expandItem(_ item: D, children: [(id: D, isLeaf: Bool)]) { 107 | guard case .collapsed = directory[item]?.state 108 | else { return } 109 | 110 | directory[item]?.state = .expanded(children: children.map(\.id)) 111 | for child in children { 112 | directory[child.id] = Node(parentID: item, isLeaf: child.isLeaf) 113 | } 114 | } 115 | 116 | /// Marks the item with the given ID as collapsed/unexpanded by 117 | /// removing its children from the TreeMap 118 | /// 119 | /// - Parameters: 120 | /// - item: The ID of the item to mark as collapsed. 121 | func collapseItem(_ item: D) { 122 | guard case let .expanded(existingChildIDs) = directory[item]?.state 123 | else { return } 124 | directory[item]?.state = .collapsed 125 | for childID in existingChildIDs { 126 | directory[childID] = nil 127 | } 128 | } 129 | 130 | /// Deletes an item from the TreeMap, along with all its children. 131 | /// 132 | /// - Parameter item: The ID of the item to remove. 133 | func removeItem(_ item: D) { 134 | // Remove all children from tree 135 | if case let .expanded(childIDs) = directory[item]?.state { 136 | childIDs.forEach { removeItem($0) } 137 | } 138 | 139 | // remove from parent 140 | if let parentID = directory[item]?.parentID, 141 | case var .expanded(siblingIDs) = directory[parentID]?.state, 142 | let childIdx = siblingIDs.firstIndex(of: item) 143 | { 144 | siblingIDs.remove(at: childIdx) 145 | directory[parentID]?.state = .expanded(children: siblingIDs) 146 | } 147 | 148 | // remove from root 149 | if let rootIdx = rootData.firstIndex(of: item) { 150 | rootData.remove(at: rootIdx) 151 | } 152 | 153 | // remove from directory 154 | directory[item] = nil 155 | } 156 | 157 | /// Gets the IDs of the children of the selected item if it is expanded. 158 | /// 159 | /// - Parameter item: the item to check for children. 160 | /// - Returns: An array of IDs of children if the item if it happens to 161 | /// be an internal node and is currently expanded. 162 | func currentChildrenOfItem(_ item: D) -> [D]? { 163 | switch directory[item]?.state { 164 | case let .expanded(children): 165 | return children 166 | default: 167 | return nil 168 | } 169 | } 170 | 171 | /// Tests whether the selected item is a leaf or an internal node that can expand. 172 | /// 173 | /// - Parameter item: the ID of the item to test 174 | /// - Returns: A boolean indicating whether the item can be expanded. 175 | func isItemExpandable(_ item: D) -> Bool { 176 | switch directory[item]?.state { 177 | case .none, .leaf: 178 | return false 179 | default: 180 | return true 181 | } 182 | } 183 | 184 | /// Tests whether the selected item is an expanded internal node. 185 | /// 186 | /// - Parameter item: The ID of the item to test. 187 | /// - Returns: A boolean indicating whether the item is currently expanded. 188 | func isItemExpanded(_ item: D) -> Bool { 189 | switch directory[item]?.state { 190 | case .expanded(_): 191 | return true 192 | default: 193 | return false 194 | } 195 | } 196 | 197 | /// Finds the full parentage of the selected item all the way from the root 198 | /// of the TreeMap 199 | /// 200 | /// - Parameter item: the ID of the item to search. 201 | /// - Returns: nil if the item doesn't exist in the TreeMap, otherwise an array 202 | /// of IDs to follow from the root of the TreeMap to get to the selected item, 203 | /// ending with the ID of the selected item. 204 | func lineageOfItem(_ item: D) -> [D]? { 205 | guard let node = directory[item] else { return nil } 206 | 207 | var result = [item] 208 | var parent = node.parentID 209 | while let nonNilParent = parent { 210 | result.insert(nonNilParent, at: 0) 211 | parent = directory[nonNilParent]?.parentID 212 | } 213 | return result 214 | } 215 | } 216 | 217 | @available(macOS 10.15, *) 218 | extension TreeMap: CustomStringConvertible { 219 | var description: String { 220 | var strings: [String] = [] 221 | 222 | for rootItem in rootData { 223 | strings.append(contentsOf: descriptionStrings(for: rootItem)) 224 | } 225 | 226 | return strings.joined(separator: "\n") 227 | } 228 | 229 | private func descriptionStrings(for item: D) -> [String] { 230 | guard let depth = lineageOfItem(item)?.count 231 | else { 232 | assertionFailure("Cannot call `descriptionStrings(for:)` on item not in the TreeMap") 233 | return [] 234 | } 235 | let selfDescription = "\(String(repeating: "- ", count: depth))\(item)" 236 | var res: [String] = [selfDescription] 237 | if case let .expanded(childIDs) = directory[item]?.state { 238 | childIDs.forEach { res.append(contentsOf: descriptionStrings(for: $0)) } 239 | } 240 | return res 241 | } 242 | } 243 | 244 | @available(macOS 10.15, *) 245 | extension TreeMap.Node: Equatable {} 246 | 247 | @available(macOS 10.15, *) 248 | extension TreeMap: Equatable { 249 | static func == (lhs: TreeMap, rhs: TreeMap) -> Bool { 250 | lhs.directory == rhs.directory && lhs.rootData == rhs.rootData 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Sources/OutlineView/Visibility.swift: -------------------------------------------------------------------------------- 1 | /// The visibility of a row separator. 2 | public enum SeparatorVisibility: CaseIterable, Equatable, Hashable { 3 | /// The separator may be hidden. 4 | case hidden 5 | /// The separator may be visible. 6 | case visible 7 | } 8 | -------------------------------------------------------------------------------- /Tests/OutlineViewTests/OutlineViewDataSourceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OutlineView 3 | 4 | class OutlineViewDataSourceTests: XCTestCase { 5 | struct TestItem: Identifiable, Equatable { 6 | var id: Int 7 | var children: [TestItem]? 8 | } 9 | 10 | let items = [ 11 | TestItem(id: 1, children: []), 12 | TestItem(id: 2, children: nil), 13 | TestItem(id: 3, children: [TestItem(id: 4, children: nil)]), 14 | ] 15 | .map { OutlineViewItem(value: $0, children: \TestItem.children) } 16 | let itemChildren: ChildSource<[TestItem]> = .keyPath(\.children) 17 | 18 | let outlineView = NSOutlineView() 19 | 20 | typealias DataSource = OutlineViewDataSource<[TestItem], NoDropReceiver> 21 | 22 | func testInit() { 23 | let dataSource = DataSource(items: items, childSource: .keyPath(\.children)) 24 | XCTAssertEqual(dataSource.items, items) 25 | } 26 | 27 | func testNumberOfChildrenOfItem() { 28 | let dataSource = DataSource(items: items, childSource: itemChildren) 29 | 30 | XCTAssertEqual(dataSource.outlineView(outlineView, numberOfChildrenOfItem: items[0]), 0) 31 | XCTAssertEqual(dataSource.outlineView(outlineView, numberOfChildrenOfItem: items[1]), 0) 32 | XCTAssertEqual(dataSource.outlineView(outlineView, numberOfChildrenOfItem: items[2]), 1) 33 | XCTAssertEqual(dataSource.outlineView(outlineView, numberOfChildrenOfItem: items[2].children![0]), 0) 34 | } 35 | 36 | func testItemIsExpandable() { 37 | let dataSource = DataSource(items: items, childSource: itemChildren) 38 | 39 | XCTAssertEqual(dataSource.outlineView(outlineView, isItemExpandable: items[0]), true) 40 | XCTAssertEqual(dataSource.outlineView(outlineView, isItemExpandable: items[1]), false) 41 | XCTAssertEqual(dataSource.outlineView(outlineView, isItemExpandable: items[2]), true) 42 | XCTAssertEqual(dataSource.outlineView(outlineView, isItemExpandable: items[2].children![0]), false) 43 | } 44 | 45 | func testChildOfItem() throws { 46 | let dataSource = DataSource(items: items, childSource: itemChildren) 47 | 48 | XCTAssertEqual(try XCTUnwrap(dataSource.outlineView(outlineView, child: 0, ofItem: nil) as? OutlineViewItem<[TestItem]>), items[0]) 49 | XCTAssertEqual(try XCTUnwrap(dataSource.outlineView(outlineView, child: 1, ofItem: nil) as? OutlineViewItem<[TestItem]>), items[1]) 50 | XCTAssertEqual(try XCTUnwrap(dataSource.outlineView(outlineView, child: 2, ofItem: nil) as? OutlineViewItem<[TestItem]>), items[2]) 51 | XCTAssertEqual(try XCTUnwrap(dataSource.outlineView(outlineView, child: 0, ofItem: items[2]) as? OutlineViewItem<[TestItem]>), items[2].children![0]) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/OutlineViewTests/OutlineViewItemTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OutlineView 3 | 4 | class OutlineViewItemTests: XCTestCase { 5 | struct TestItem: Identifiable, Equatable { 6 | var id: Int 7 | var children: [TestItem]? 8 | } 9 | 10 | let testItem = TestItem( 11 | id: 1, 12 | children: [ 13 | TestItem(id: 2, children: nil), 14 | TestItem(id: 3, children: nil), 15 | TestItem(id: 4, children: nil), 16 | ]) 17 | 18 | func testInit() { 19 | let outlineItem = OutlineViewItem(value: testItem, children: \TestItem.children) 20 | 21 | XCTAssertEqual(outlineItem.value, testItem) 22 | XCTAssertEqual(outlineItem.children?.map(\.value), testItem.children) 23 | } 24 | 25 | func testEquatable() { 26 | let firstOutlineItem = OutlineViewItem(value: testItem, children: \TestItem.children) 27 | 28 | let otherItem = TestItem(id: 1, children: nil) 29 | let secondOutlineItem = OutlineViewItem(value: otherItem, children: \TestItem.children) 30 | 31 | // Even though testItem and otherItem are not equal, 32 | // the OutlineViewItem should still be equal, as it derives its 33 | // Equatable conformance from the identity (id) of the value it wraps. 34 | XCTAssertNotEqual(testItem, otherItem) 35 | XCTAssertEqual(firstOutlineItem, secondOutlineItem) 36 | } 37 | 38 | func testHashable() { 39 | let firstOutlineItem = OutlineViewItem(value: testItem, children: \TestItem.children) 40 | 41 | let otherItem = TestItem(id: 1, children: nil) 42 | let secondOutlineItem = OutlineViewItem(value: otherItem, children: \TestItem.children) 43 | 44 | // Even though TestItem does not conform to Hashable, the 45 | // OutlineViewItem wrapping the TestItem will derives its 46 | // Hashable conformance from the identity (id) of the value it wraps. 47 | XCTAssertEqual(firstOutlineItem.hashValue, secondOutlineItem.hashValue) 48 | } 49 | 50 | func testIdentifiable() { 51 | let outlineItem = OutlineViewItem(value: testItem, children: \TestItem.children) 52 | 53 | XCTAssertEqual(outlineItem.id, testItem.id) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Tests/OutlineViewTests/OutlineViewUpdaterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OutlineView 3 | 4 | class OutlineViewUpdaterTests: XCTestCase { 5 | struct TestItem: Identifiable, Equatable { 6 | var id: Int 7 | var children: [TestItem]? 8 | } 9 | 10 | let oldState = [ 11 | TestItem(id: 0, children: nil), 12 | TestItem(id: 1, children: []), 13 | TestItem(id: 2, children: nil), 14 | TestItem(id: 3, children: [TestItem(id: 4, children: nil)]), 15 | TestItem(id: 5, children: [TestItem(id: 6, children: [TestItem(id: 7, children: nil)])]), 16 | ] 17 | .map { OutlineViewItem(value: $0, children: \TestItem.children) } 18 | 19 | let newState = [ 20 | TestItem(id: 0, children: []), 21 | TestItem(id: 1, children: [TestItem(id: 4, children: nil)]), 22 | TestItem(id: 3, children: []), 23 | TestItem(id: 5, children: [TestItem(id: 6, children: nil)]), 24 | TestItem(id: 8, children: nil), 25 | ] 26 | .map { OutlineViewItem(value: $0, children: \TestItem.children) } 27 | 28 | func testPerformUpdates() { 29 | let outlineView = TestOutlineView() 30 | var updater = OutlineViewUpdater<[TestItem]>() 31 | updater.assumeOutlineIsExpanded = true 32 | 33 | let oldStateTree = TreeMap(rootItems: oldState, itemIsExpanded: { _ in true }) 34 | 35 | updater.performUpdates( 36 | outlineView: outlineView, 37 | oldStateTree: oldStateTree, 38 | newState: newState, 39 | parent: nil) 40 | 41 | XCTAssertEqual( 42 | outlineView.insertedItems.sorted(), 43 | [ 44 | UpdatedItem(parent: nil, index: 4), 45 | UpdatedItem(parent: 1, index: 0), 46 | ]) 47 | 48 | XCTAssertEqual( 49 | outlineView.removedItems.sorted(), 50 | [ 51 | UpdatedItem(parent: nil, index: 2), 52 | UpdatedItem(parent: 3, index: 0), 53 | UpdatedItem(parent: 6, index: 0), 54 | ]) 55 | 56 | XCTAssertEqual( 57 | outlineView.reloadedItems.sorted(), 58 | [nil, 0, 1, 3, 6]) 59 | } 60 | } 61 | 62 | extension OutlineViewUpdaterTests { 63 | struct UpdatedItem: Equatable, Comparable { 64 | let parent: Int? 65 | let index: Int 66 | 67 | static func < (lhs: Self, rhs: Self) -> Bool { 68 | switch ((lhs.parent, lhs.index), (rhs.parent, rhs.index)) { 69 | case ((nil, let l), (nil, let r)): return l < r 70 | case ((nil, _), (_, _)): return true 71 | case ((_, _), (nil, _)): return false 72 | case ((let l, _), (let r, _)): return l < r 73 | } 74 | } 75 | } 76 | 77 | class TestOutlineView: NSOutlineView { 78 | typealias Item = OutlineViewItem<[TestItem]> 79 | var insertedItems = [UpdatedItem]() 80 | var removedItems = [UpdatedItem]() 81 | var reloadedItems = [Item.ID?]() 82 | 83 | override func insertItems( 84 | at indexes: IndexSet, 85 | inParent parent: Any?, 86 | withAnimation animationOptions: NSTableView.AnimationOptions = [] 87 | ) { 88 | indexes.forEach { 89 | insertedItems.append(UpdatedItem(parent: (parent as? Item)?.id, index: $0)) 90 | } 91 | } 92 | 93 | override func removeItems( 94 | at indexes: IndexSet, 95 | inParent parent: Any?, 96 | withAnimation animationOptions: NSTableView.AnimationOptions = [] 97 | ) { 98 | indexes.forEach { 99 | removedItems.append(UpdatedItem(parent: (parent as? Item)?.id, index: $0)) 100 | } 101 | } 102 | 103 | override func reloadItem( 104 | _ item: Any?, 105 | reloadChildren: Bool 106 | ) { 107 | reloadedItems.append((item as? Item)?.id) 108 | } 109 | } 110 | } 111 | 112 | extension Optional: Comparable where Wrapped: Comparable { 113 | public static func < (lhs: Self, rhs: Self) -> Bool { 114 | switch (lhs, rhs) { 115 | case (nil, _): return true 116 | case (_, nil): return false 117 | case (let l, let r): return l.unsafelyUnwrapped < r.unsafelyUnwrapped 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/OutlineViewTests/TreeMapTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OutlineView 3 | 4 | final class TreeMapTests: XCTestCase { 5 | struct TestItem: Identifiable, Equatable { 6 | var id: Int 7 | var children: [TestItem]? 8 | } 9 | 10 | /// A basic TreeMap with 5 root objects (1 through 5), 11 | /// where all but item 5 are internal nodes, and all 12 | /// are collapsed. 13 | private var testTree: TreeMap { 14 | let rootItems = (1...5) 15 | .map { TestItem(id: $0, children: $0 == 5 ? nil : []) } 16 | .map { OutlineViewItem(value: $0, children: \TestItem.children) } 17 | return TreeMap(rootItems: rootItems, itemIsExpanded: { _ in false }) 18 | } 19 | 20 | func testExpandItem() { 21 | let tree = testTree 22 | 23 | tree.expandItem(4, children: [ 24 | (41, true), 25 | (42, false) 26 | ]) 27 | 28 | let children = tree.currentChildrenOfItem(4) 29 | XCTAssertEqual(children, [41, 42]) 30 | } 31 | 32 | func testIsItemExpandable() { 33 | let tree = testTree 34 | 35 | tree.expandItem(4, children: [ 36 | (41, true), 37 | (42, false) 38 | ]) 39 | 40 | XCTAssertTrue(tree.isItemExpandable(1)) 41 | XCTAssertTrue(tree.isItemExpandable(2)) 42 | XCTAssertTrue(tree.isItemExpandable(3)) 43 | XCTAssertTrue(tree.isItemExpandable(4)) 44 | XCTAssertFalse(tree.isItemExpandable(5)) 45 | 46 | XCTAssertTrue(tree.isItemExpandable(42)) 47 | XCTAssertFalse(tree.isItemExpandable(41)) 48 | } 49 | 50 | func testIsItemExpanded() { 51 | let tree = testTree 52 | 53 | tree.expandItem(4, children: [ 54 | (41, true), 55 | (42, false) 56 | ]) 57 | 58 | for n in [1, 2, 3, 5, 41, 42] { 59 | XCTAssertFalse(tree.isItemExpanded(n)) 60 | } 61 | 62 | XCTAssertTrue(tree.isItemExpanded(4)) 63 | } 64 | 65 | func testCurrentChildrenOfItems() { 66 | let tree = testTree 67 | 68 | tree.expandItem(4, children: [ 69 | (41, true), 70 | (42, false) 71 | ]) 72 | 73 | XCTAssertEqual([41, 42], tree.currentChildrenOfItem(4)) 74 | } 75 | 76 | func testAddItems() { 77 | let tree = testTree 78 | 79 | tree.expandItem(4, children: [ 80 | (41, true), 81 | (44, false) 82 | ]) 83 | 84 | tree.addItem(42, isLeaf: true, intoItem: 4, atIndex: 1) 85 | tree.addItem(43, isLeaf: false, intoItem: 4, atIndex: 2) 86 | tree.addItem(45, isLeaf: true, intoItem: 4, atIndex: nil) 87 | 88 | XCTAssertEqual(Array(41...45), tree.currentChildrenOfItem(4)) 89 | } 90 | 91 | func testCollapseItems() { 92 | let tree = testTree 93 | 94 | tree.expandItem(4, children: [ 95 | (41, true), 96 | (42, false) 97 | ]) 98 | 99 | tree.collapseItem(4) 100 | 101 | XCTAssertFalse(tree.isItemExpanded(4)) 102 | XCTAssertEqual(tree.currentChildrenOfItem(4), nil) 103 | } 104 | 105 | func testRemoveItems() { 106 | let tree = testTree 107 | 108 | tree.expandItem( 109 | 4, 110 | children: (41...45).map({ ($0, false) }) 111 | ) 112 | 113 | tree.expandItem( 114 | 45, 115 | children: (451...455).map({ ($0, false) }) 116 | ) 117 | 118 | let allIds = [1, 2, 3, 4, 5, 41, 42, 43, 44, 45, 451, 452, 453, 454, 455] 119 | XCTAssertEqual(Set(allIds), tree.allItemIds) 120 | tree.removeItem(4) 121 | XCTAssertEqual(tree.allItemIds, Set([1, 2, 3, 5])) 122 | } 123 | 124 | func testLineageOfItem() { 125 | let tree = testTree 126 | 127 | tree.expandItem( 128 | 4, 129 | children: (41...45).map({ ($0, false) }) 130 | ) 131 | 132 | tree.expandItem( 133 | 45, 134 | children: (451...455).map({ ($0, false) }) 135 | ) 136 | 137 | XCTAssertEqual(tree.lineageOfItem(455), [4, 45, 455]) 138 | } 139 | } 140 | --------------------------------------------------------------------------------