Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/sameesunkaria/outlineview
OutlineView for SwiftUI on macOS
https://github.com/sameesunkaria/outlineview
appkit cocoa macos swift swiftui
Last synced: about 1 month ago
JSON representation
OutlineView for SwiftUI on macOS
- Host: GitHub
- URL: https://github.com/sameesunkaria/outlineview
- Owner: Sameesunkaria
- License: mit
- Created: 2020-11-29T11:01:13.000Z (about 4 years ago)
- Default Branch: main
- Last Pushed: 2023-04-01T17:07:15.000Z (over 1 year ago)
- Last Synced: 2023-05-29T19:29:51.895Z (over 1 year ago)
- Topics: appkit, cocoa, macos, swift, swiftui
- Language: Swift
- Homepage:
- Size: 291 KB
- Stars: 49
- Watchers: 1
- Forks: 7
- Open Issues: 7
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# OutlineView for SwiftUI on macOS
`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.
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`.
## Installation
You can install the `OutlineView` package using SwiftPM.
```
https://github.com/Sameesunkaria/OutlineView.git
```## Usage
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.
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.
A macOS app demonstrating this example can be found in the `Example` directory.
```swift
struct FileItem: Hashable, Identifiable, CustomStringConvertible {
// Each item in the hierarchy should be uniquely identified.
var id = UUID()
var name: String
var children: [FileItem]? = nil
var description: String {
switch children {
case nil:
return "📄 \(name)"
case .some(let children):
return children.isEmpty ? "📂 \(name)" : "📁 \(name)"
}
}
}let data = [
FileItem(
name: "user1234",
children: [
FileItem(
name: "Photos",
children: [
FileItem(name: "photo001.jpg"),
FileItem(name: "photo002.jpg")]),
FileItem(
name: "Movies",
children: [FileItem(name: "movie001.mp4")]),
FileItem(name: "Documents", children: [])]),
FileItem(
name: "newuser",
children: [FileItem(name: "Documents", children: [])])
]@State var selection: FileItem?
OutlineView(data, selection: $selection, children: \.children) { item in
NSTextField(string: item.description)
}
```### Customization
#### Children
There are two types of `.children` parameters in the `OutlineView` initializers. You either provide the children for an item using:
- A `KeyPath` pointing to an optional `Sequence` of the same type as the root data.
- A closure that returns an optional `Sequence` of the same type as the root data, based on the parent item.```swift
// By passing a KeyPath to the children:
OutlineView(data, children: \.children, selection: $selection) { item in
NSTextField(string: item.description)
}// By providing a closure that returns the children:
OutlineView(data, selection: $selection) { item in
dataSource.childrenOfItem(item)
} content: { item in
NSTextField(string: item.description)
}
```#### Style
You can customize the look of the `OutlineView` by providing a preferred style (`NSOutlineView.Style`) in the `outlineViewStyle` method. The default value is `.automatic`.```swift
OutlineView(data, selection: $selection, children: \.children) { item in
NSTextField(string: item.description)
}
.outlineViewStyle(.sourceList)
```#### Indentation
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`.
```swift
OutlineView(data, selection: $selection, children: \.children) { item in
NSTextField(string: item.description)
}
.outlineViewIndentation(20)
```#### Displaying separators
You can customize the `OutlineView` to display row separators by using the `rowSeparator` modifier.
```swift
OutlineView(data, selection: $selection, children: \.children) { item in
NSTextField(string: item.description)
}
.rowSeparator(.visible)
```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.
>Note: This initializer is only available on macOS 11.0 and higher.
```swift
let separatorInset = NSEdgeInsets(top: 0, left: 24, bottom: 0, right: 0)OutlineView(
data,
selection: $selection,
children: \.children,
separatorInsets: { item in separatorInset }) { item in
NSTextField(string: item.description)
}
```#### Row separator color
You can customize the color of the row separators of the `OutlineView`. The default color is `NSColor.separatorColor`.
```swift
OutlineView(data, selection: $selection, children: \.children) { item in
NSTextField(string: item.description)
}
.rowSeparator(.visible)
.rowSeparatorColor(.red)
```### Drag & Drop
#### Dragging From `OutlineView`
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).
```swift
extension NSPasteboard.PasteboardType {
static var myPasteboardType: Self {
PasteboardType("MySpecialPasteboardIdentifier")
}
}outlineView
.dragDataSource { item in
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setData(item.dataRepresentation, forType: .myPasteboardType)
return pasteboardItem
}
```#### Dropping into `OutlineView`
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.
```swift
outlineView
.onDrop(of: [.myPasteboardType, .fileUrl], receiver: MyDropReceiver())
class MyDropReceiver: DropReceiver {
func readPasteboard(item: NSPasteboardItem) -> DraggedItem? {
guard let pasteboardType = item.availableType(from: pasteboardTypes) else { return nil }
switch pasteboardType {
case .myPasteboardType:
if let draggedData = item.data(forType: .myPasteboardType) {
let draggedFileItem = /* instance of OutlineView.Data.Element from draggedData */
return (draggedFileItem, .myPasteboardType)
} else {
return nil
}
case .fileUrl:
if let draggedUrlString = item.string(forType: .fileUrl),
draggedUrl = URL(string: draggedUrlString)
{
let newFileItem = /* instance of OutlineView.Data.Element from draggedUrl */
return (newFileItem, .fileUrl)
} else {
return nil
}
default:
return nil
}
}
func validateDrop(target: DropTarget) -> ValidationResult {
let draggedItems = target.draggedItems
if draggedItems[0].type == .myPasteboardType {
return .move
} else if draggedItems[0].type == .fileUrl {
return .copy
} else {
return .deny
}
}
func acceptDrop(target: DropTarget) -> Bool {
// update data source to reflect that drop was successful or not
return dropWasSuccessful
}
}
```For more details on the various types needed in `onDrop`, see `OutlineViewDragAndDrop.swift`, and the sample app `OutlineViewDraggingExample`.
## Why use `OutlineView` instead of the native `List` with children?
`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.
- 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.
- 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.
- Unlike SwiftUI's native `OutlineGroup` or `List` with children, `OutlineView` supports macOS 10.15 Catalina.
- `OutlineView` supports row animations for updates by default.## Caveats
`OutlineView` is implemented using the public API for SwiftUI, leading to some limitations that are hard to workaround.
- 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.
- 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.)