Skip to content

Commit

Permalink
Big changes. Supporting new grid-based list. (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
reedes authored Feb 27, 2022
1 parent 840b8f4 commit 3a6c0ba
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 179 deletions.
101 changes: 49 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A multi-platform SwiftUI component for displaying (and interacting with) tabular data.

Available as an open source Swift library to be incorporated in other apps.
Available as an open source library to be incorporated in SwiftUI apps.

_SwiftTabular_ is part of the [OpenAlloc](https://github.com/openalloc) family of open source Swift software tools.

Expand All @@ -21,12 +21,15 @@ macOS | iOS
* No View type erasure (i.e., use of `AnyView`) which can impact scalability and performance
* No external dependencies!

For `List`-based tables:
For List-based tables:
* Optional moving of rows through drag and drop
* Support for no-select, single-select, and multi-select
* Support for single-select and multi-select

For `ScrollView`/`LazyVStack`-based tables:
* Support for no-select, single-select (possibily multi-select in future)
For ScrollView/LazyVStack-based tables:
* Support for single-select (possibily multi-select in future)

For ScrollView/LazyVGrid-based tables:
* Likely the most scalable and efficient, but least flexible

On macOS:
* Hovering highlight, indicating which row the mouse is over
Expand Down Expand Up @@ -66,25 +69,20 @@ struct ContentView: View {
GridItem(.flexible(minimum: 35, maximum: 50), alignment: .leading),
]

@ViewBuilder
private func header(_ ctx: TablerSortContext<Fruit>) -> some View {
LazyVGrid(columns: gridItems) {
Text("ID")
Text("Name")
Text("Weight")
Text("Color")
}
.padding(.horizontal)
Text("ID")
Text("Name")
Text("Weight")
Text("Color")
}

@ViewBuilder
private func row(_ element: Fruit) -> some View {
LazyVGrid(columns: gridItems) {
Text(element.id)
Text(element.name).foregroundColor(element.color)
Text(String(format: "%.0f g", element.weight))
Image(systemName: "rectangle.fill").foregroundColor(element.color)
}
.padding(.horizontal)
.padding(.vertical, 5)
Text(element.id)
Text(element.name).foregroundColor(element.color)
Text(String(format: "%.0f g", element.weight))
Image(systemName: "rectangle.fill").foregroundColor(element.color)
}

var body: some View {
Expand All @@ -96,7 +94,7 @@ struct ContentView: View {
}

private var config: TablerListConfig<Fruit> {
TablerListConfig<Fruit>()
TablerListConfig<Fruit>(gridItems: gridItems)
}
}
```
Expand All @@ -105,23 +103,24 @@ struct ContentView: View {

You can choose from any of ten (10) variants, which break down along the following lines:

* Both `List`-based and `ScrollView`/`LazyVStack`-based.
* Selection types offered: none, single-select, and multi-select (`List` only presently)
* List-based, ScrollView/LazyVStack-based, and ScrollView/LazyVGrid-based
* Selection types offered: none, single-select, and multi-select, depending on base
* Unbound elements in row view, where you're presenting table rows read-only\*
* Bound elements in row view, where you're presenting tables rows that can be updated directly (see Bound section below)

| Base | Selection of rows | Element wrapping | View name |
| --- | --- | --- | --- |
| `List` | No Select | (none) | `TablerList` |
| `List` | No Select | Binding\<Element> | `TablerListB` |
| `List` | Single-select | (none) | `TablerList1` |
| `List` | Single-select | Binding\<Element> | `TablerList1B` |
| `List` | Multi-select | (none) | `TablerListM` |
| `List` | Multi-select | Binding\<Element> | `TablerListMB` |
| Stack | No Select | (none) | `TablerStack` |
| Stack | No Select | Binding\<Element> | `TablerStackB` |
| Stack | Single-select | (none) | `TablerStack1` |
| Stack | Single-select | Binding\<Element> | `TablerStack1B` |
Base | Selection of rows | Element wrapping | View name | Notes
--- | --- | --- | --- | ---
List | No Select | (none) | TablerList |
List | No Select | Binding\<Element> | TablerListB |
List | Single-select | (none) | TablerList1 |
List | Single-select | Binding\<Element> | TablerList1B |
List | Multi-select | (none) | TablerListM |
List | Multi-select | Binding\<Element> | TablerListMB |
Stack | No Select | (none) | TablerStack |
Stack | No Select | Binding\<Element> | TablerStackB |
Stack | Single-select | (none) | TablerStack1 |
Stack | Single-select | Binding\<Element> | TablerStack1B |
Grid | No Select | (none) | TablerGrid | Experimental. Needs bound version, select, etc.

\* 'unbound' variants can be used with Core Data (where values are bound by alternative means)

Expand All @@ -132,16 +131,15 @@ Column sorting is available through `tablerSort` view function.
From the demo app, an example of using the sort capability:

```swift
@ViewBuilder
private func header(_ ctx: TablerSortContext<Fruit>) -> some View {
LazyVGrid(columns: gridItems) {
Text("ID \(Sort.indicator(ctx, \.id))")
.onTapGesture { tablerSort(ctx, &fruits, \.id) { $0.id < $1.id } }
Text("Name \(Sort.indicator(ctx, \.name))")
.onTapGesture { tablerSort(ctx, &fruits, \.name) { $0.name < $1.name } }
Text("Weight \(Sort.indicator(ctx, \.weight))")
.onTapGesture { tablerSort(ctx, &fruits, \.weight) { $0.weight < $1.weight } }
Text("Color")
}
Text("ID \(Sort.indicator(ctx, \.id))")
.onTapGesture { tablerSort(ctx, &fruits, \.id) { $0.id < $1.id } }
Text("Name \(Sort.indicator(ctx, \.name))")
.onTapGesture { tablerSort(ctx, &fruits, \.name) { $0.name < $1.name } }
Text("Weight \(Sort.indicator(ctx, \.weight))")
.onTapGesture { tablerSort(ctx, &fruits, \.weight) { $0.weight < $1.weight } }
Text("Color")
}
```

Expand All @@ -156,15 +154,14 @@ macOS | iOS
When used with 'bound' variants (e.g., `TablerListB`), the data can be modified directly, mutating your data source. From the demo:

```swift
@ViewBuilder
private func brow(_ element: Binding<Fruit>) -> some View {
LazyVGrid(columns: gridItems) {
Text(element.wrappedValue.id)
TextField("Name", text: element.name)
.textFieldStyle(.roundedBorder)
Text(String(format: "%.0f g", element.wrappedValue.weight))
ColorPicker("Color", selection: element.color)
.labelsHidden()
}
Text(element.wrappedValue.id)
TextField("Name", text: element.name)
.textFieldStyle(.roundedBorder)
Text(String(format: "%.0f g", element.wrappedValue.weight))
ColorPicker("Color", selection: element.color)
.labelsHidden()
}
```

Expand Down
53 changes: 53 additions & 0 deletions Sources/Grid/Internal/BaseGrid.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// BaseGrid.swift
//
// Copyright 2022 FlowAllocator LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI

// Grid-based list
struct BaseGrid<Element, Header, Rows>: View
where Element: Identifiable,
Header: View,
Rows: View
{
typealias Config = TablerGridConfig<Element>
typealias HeaderContent = (Binding<TablerSort<Element>?>) -> Header
typealias RowContent = () -> Rows

let config: Config
@ViewBuilder let headerContent: HeaderContent
@ViewBuilder let rowsContent: RowContent

var body: some View {
BaseTable(config: config,
headerContent: headerContent) { buildHeader in

VStack(spacing: config.rowSpacing) {
buildHeader()

ScrollView {
LazyVGrid(columns: config.gridItems,
alignment: config.alignment,
spacing: config.rowSpacing) {
rowsContent()
}
}
}
.padding(config.paddingInsets)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// BaseListRow.swift
// BaseGridRow.swift
//
// Copyright 2022 FlowAllocator LLC
//
Expand All @@ -18,39 +18,43 @@

import SwiftUI

struct BaseListRow<Element, Content>: View
// Row for stack-based list
struct BaseGridRow<Element, Row>: View
where Element: Identifiable,
Content: View
Row: View
{
typealias Config = TablerListConfig<Element>
typealias Config = TablerGridConfig<Element>
typealias Hovered = Element.ID?
typealias RowContent = () -> Row

// MARK: Parameters

var config: Config
var element: Element
@Binding var hovered: Hovered
var content: () -> Content
var rowContent: RowContent

// MARK: Views

var body: some View {
let colorPair = config.onRowColor?(element) // NOTE okay if nil

content()
.moveDisabled(!config.canMove(element))

rowContent()
//.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(colorPair?.0 ?? Color.primary)

// Colored rows get their background here.
// For non-colored rows, use accent color background to indicate selection.
#if os(macOS) || targetEnvironment(macCatalyst)
// support hovering, but not for colored rows (yet)
// no background for colored rows (yet)
.onHover { if $0 { hovered = element.id } }
.background((colorPair == nil && hovered == element.id)
? Color.accentColor.opacity(0.2)
: Color.clear)
// support hovering, but not for colored rows (yet)
.onHover { if $0 { hovered = element.id } }

// If hovering, set the background here.
.background(colorPair?.1 ?? (
hovered == element.id ? Color.accentColor.opacity(0.2) : Color.clear
))
#elseif os(iOS)
.background(colorPair?.1 ?? Color.clear)
#endif

.listRowBackground(colorPair?.1 ?? Color.clear)
}
}
86 changes: 86 additions & 0 deletions Sources/Grid/TablerGrid.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// TablerGrid.swift
//
// Copyright 2022 FlowAllocator LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI

/// Grid-based table
public struct TablerGrid<Element, Header, Row, Results>: View
where Element: Identifiable,
Header: View,
Row: View,
Results: RandomAccessCollection,
Results.Element == Element
{
public typealias Config = TablerGridConfig<Element>
public typealias Hovered = Element.ID?
public typealias HeaderContent = (Binding<TablerSort<Element>?>) -> Header
public typealias RowContent = (Element) -> Row

// MARK: Parameters

private let config: Config
private let headerContent: HeaderContent
private let rowContent: RowContent
private var results: Results

public init(_ config: Config,
@ViewBuilder headerContent: @escaping HeaderContent,
@ViewBuilder rowContent: @escaping RowContent,
results: Results)
{
self.config = config
self.headerContent = headerContent
self.rowContent = rowContent
self.results = results
}

// MARK: Locals

@State private var hovered: Hovered = nil

// MARK: Views

public var body: some View {
BaseGrid(config: config,
headerContent: headerContent) {
ForEach(results.filter(config.filter ?? { _ in true })) { element in
BaseGridRow(config: config,
element: element,
hovered: $hovered) {

// TODO how to provide a continuous hover block (selection, etc.)?
rowContent(element)
}
}
}
}
}

public extension TablerGrid {
// omitting Header
init(_ config: Config,
@ViewBuilder rowContent: @escaping RowContent,
results: Results)
where Header == EmptyView
{
self.init(config,
headerContent: { _ in EmptyView() },
rowContent: rowContent,
results: results)
}
}
Loading

0 comments on commit 3a6c0ba

Please sign in to comment.