├── .gitignore
├── Examples
├── Screenshot.png
├── OutlineViewExample
│ ├── OutlineViewExample
│ │ ├── OutlineViewExample.xcconfig
│ │ ├── Assets.xcassets
│ │ │ ├── Contents.json
│ │ │ ├── AccentColor.colorset
│ │ │ │ └── Contents.json
│ │ │ └── AppIcon.appiconset
│ │ │ │ └── Contents.json
│ │ ├── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ │ └── Contents.json
│ │ ├── OutlineViewExampleApp.swift
│ │ ├── OutlineViewExample.entitlements
│ │ ├── Info.plist
│ │ ├── FileItemView.swift
│ │ └── ContentView.swift
│ └── OutlineViewExample.xcodeproj
│ │ ├── project.xcworkspace
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ │ └── project.pbxproj
└── OutlineViewDraggingExample
│ ├── OutlineViewDraggingExample
│ ├── OutlineViewDraggingExample.xcconfig
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── OutlineViewDraggingExampleApp.swift
│ ├── OutlineViewDraggingExample.entitlements
│ ├── Info.plist
│ ├── FileItemView.swift
│ ├── ContentView.swift
│ └── ViewModel.swift
│ └── OutlineViewDraggingExample.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ ├── xcshareddata
│ └── xcschemes
│ │ └── OutlineViewDraggingExample.xcscheme
│ └── project.pbxproj
├── Sources
└── OutlineView
│ ├── NSEdgeInsets+Zero.swift
│ ├── NSEdgeInsets+Equatable.swift
│ ├── Visibility.swift
│ ├── StringMangling.swift
│ ├── Notifications.swift
│ ├── OutlineViewItem.swift
│ ├── AdjustableSeparatorRowView.swift
│ ├── OutlineViewUpdater.swift
│ ├── OutlineViewController.swift
│ ├── OutlineViewDelegate.swift
│ ├── OutlineViewDragAndDrop.swift
│ ├── OutlineViewDataSource.swift
│ ├── TreeMap.swift
│ └── OutlineView.swift
├── CHANGELOG.md
├── Package.swift
├── LICENSE.txt
├── Tests
└── OutlineViewTests
│ ├── OutlineViewItemTests.swift
│ ├── OutlineViewDataSourceTests.swift
│ ├── TreeMapTests.swift
│ └── OutlineViewUpdaterTests.swift
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | .swiftpm
7 |
8 |
--------------------------------------------------------------------------------
/Examples/Screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sameesunkaria/OutlineView/HEAD/Examples/Screenshot.png
--------------------------------------------------------------------------------
/Examples/OutlineViewExample/OutlineViewExample/OutlineViewExample.xcconfig:
--------------------------------------------------------------------------------
1 | SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM}
2 |
--------------------------------------------------------------------------------
/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/OutlineViewDraggingExample.xcconfig:
--------------------------------------------------------------------------------
1 | SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM}
2 |
--------------------------------------------------------------------------------
/Examples/OutlineViewExample/OutlineViewExample/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/OutlineView/NSEdgeInsets+Zero.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | extension NSEdgeInsets {
4 | static var zero: NSEdgeInsets {
5 | NSEdgeInsetsZero
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Examples/OutlineViewExample/OutlineViewExample/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/OutlineViewExample/OutlineViewExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------