A multi-platform SwiftUI component for tabular data.
Available as an open source library to be incorporated in SwiftUI apps.
SwiftTabular is part of the OpenAlloc family of open source Swift software tools.
macOS | iOS |
---|---|
![]() |
![]() |
RandomAccessCollection
data sourcesAnyView
), which can impact scalability and performance**Three table types are supported, as determined by the mechanism by which their header and rows are rendered.
List
ScrollView
/LazyVStack
ScrollView
/LazyVGrid
* Other platforms like macCatalyst, iPad on Mac, watchOS, tvOS, etc. are poorly supported, if at all. Please contribute to improve support!
** AnyView only used to specify sort images in configuration, which shouldn't impact scalability.
The example below shows the display of tabular data from an array of values using TablerList
, a simple variant based on List
.
import SwiftUI
import Tabler
struct Fruit: Identifiable {
var id: String
var name: String
var weight: Double
var color: Color
}
struct ContentView: View {
@State private var fruits: [Fruit] = [
Fruit(id: "🍌", name: "Banana", weight: 118, color: .brown),
Fruit(id: "🍓", name: "Strawberry", weight: 12, color: .red),
Fruit(id: "🍊", name: "Orange", weight: 190, color: .orange),
Fruit(id: "🥝", name: "Kiwi", weight: 75, color: .green),
Fruit(id: "🍇", name: "Grape", weight: 7, color: .purple),
Fruit(id: "🫐", name: "Blueberry", weight: 2, color: .blue),
]
private var gridItems: [GridItem] = [
GridItem(.flexible(minimum: 35, maximum: 40), alignment: .leading),
GridItem(.flexible(minimum: 100), alignment: .leading),
GridItem(.flexible(minimum: 40, maximum: 80), alignment: .trailing),
GridItem(.flexible(minimum: 35, maximum: 50), alignment: .leading),
]
private typealias Context = TablerContext<Fruit>
private func header(ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems) {
Text("ID")
Text("Name")
Text("Weight")
Text("Color")
}
}
private func row(fruit: Fruit) -> some View {
LazyVGrid(columns: gridItems) {
Text(fruit.id)
Text(fruit.name).foregroundColor(fruit.color)
Text(String(format: "%.0f g", fruit.weight))
Image(systemName: "rectangle.fill").foregroundColor(fruit.color)
}
}
var body: some View {
TablerList(header: header,
row: row,
results: fruits)
}
}
While LazyVGrid
is used here to wrap the header and row items, you could alternatively wrap them with HStack
or similar mechanism.
Tabler offers twenty-seven (27) variants of table views from which you can choose. They break down along the following lines:
List
ScrollView
/LazyVStack
ScrollView
/LazyVGrid
TextField
, etc.) to mutate modelconfig.filter
is supported (see caveat below)Table View | Type | Select | Value | Reference | Bound | Filter |
---|---|---|---|---|---|---|
TablerList |
List | ✓ | ✓ | ✓ | ||
TablerListB |
List | ✓ | ✓ | ✓* | ||
TablerListC |
List | ✓ | ✓ | |||
TablerList1 |
List | Single | ✓ | ✓ | ✓ | |
TablerList1B |
List | Single | ✓ | ✓ | ✓* | |
TablerList1C |
List | Single | ✓ | ✓ | ||
TablerListM |
List | Multi | ✓ | ✓ | ✓ | |
TablerListMB |
List | Multi | ✓ | ✓ | ✓* | |
TablerListMC |
List | Multi | ✓ | ✓ | ||
TablerStack |
Stack | ✓ | ✓ | ✓ | ||
TablerStackB |
Stack | ✓ | ✓ | ✓* | ||
TablerStackC |
Stack | ✓ | ✓ | |||
TablerStack1 |
Stack | Single | ✓ | ✓ | ✓ | |
TablerStack1B |
Stack | Single | ✓ | ✓ | ✓* | |
TablerStack1C |
Stack | Single | ✓ | ✓ | ||
TablerStackM |
Stack | Multi | ✓ | ✓ | ✓ | |
TablerStackMB |
Stack | Multi | ✓ | ✓ | ✓* | |
TablerStackMC |
Stack | Multi | ✓ | ✓ | ||
TablerGrid |
Grid | ✓ | ✓ | ✓ | ||
TablerGridB |
Grid | ✓ | ✓ | |||
TablerGridC |
Grid | ✓ | ✓ | |||
TablerGrid1 |
Grid | Single | ✓ | ✓ | ✓ | |
TablerGrid1B |
Grid | Single | ✓ | ✓ | ||
TablerGrid1C |
Grid | Single | ✓ | ✓ | ||
TablerGridM |
Grid | Multi | ✓ | ✓ | ✓ | |
TablerGridMB |
Grid | Multi | ✓ | ✓ | ||
TablerGridMC |
Grid | Multi | ✓ | ✓ |
* filtering with bound values likely not scalable as implemented. If you can find a better way to implement, please submit a pull request!
Optionally attach a header (or footer) to your table:
var body: some View {
TablerList(header: header,
footer: footer,
row: row,
results: fruits)
}
private func header(ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems) {
Text("ID")
Text("Name")
Text("Weight")
Text("Color")
}
}
private func footer(ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems) {
Text("ID")
Text("Name")
Text("Weight")
Text("Color")
}
}
Where you don't want a header (or footer), simply omit from the declaration of the table.
For List based variants, the header and footer are inside the scrolling region. For Stack and Grid based variants, they are outside. (This may be configurable at some point once any scaling/performance issues are resolved.)
Column sorting is available through the tablerSort
view function.
The examples below show how the header items can support sort.
.columnTitle()
is a convenience function that displays header name along with an indicator showing the current sort state, if any. Alternatively, build your own header and call the .indicator()
method to get the active indicator image.
Caret images are used by default for indicators, but are configurable (see Configuration section below).
From the TablerDemo app:
private typealias Context = TablerContext<Fruit>
private typealias Sort = TablerSort<Fruit>
private func header(ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems) {
Sort.columnTitle("ID", ctx, \.id)
.onTapGesture { tablerSort(ctx, &fruits, \.id) { $0.id < $1.id } }
Sort.columnTitle("Name", ctx, \.name)
.onTapGesture { tablerSort(ctx, &fruits, \.name) { $0.name < $1.name } }
Sort.columnTitle("Weight", ctx, \.weight)
.onTapGesture { tablerSort(ctx, &fruits, \.weight) { $0.weight < $1.weight } }
Text("Color")
}
}
The sort method used with Core Data differs. From the TablerCoreDemo app:
private typealias Context = TablerContext<Fruit>
private typealias Sort = TablerSort<Fruit>
private func header(ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems, alignment: .leading) {
Sort.columnTitle("ID", ctx, \.id)
.onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.id)] }
Sort.columnTitle("Name", ctx, \.name)
.onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.name)] }
Sort.columnTitle("Weight", ctx, \.weight)
.onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.weight)] }
}
}
Where there is no key path available to store in the sort context, such as for a computed value, create a place holder key path.
extension Holding {
func getMarketValue(_ priceMap: [String: Double]) -> Double {
shareCount * (priceMap[ticker] ?? 0)
}
var marketValuePlaceholder: Double { 0 }
}
struct HoldingsTable: View {
private typealias Context = TablerContext<Holding>
private func header(_ ctx: Binding<Context>) -> some View {
LazyVGrid(columns: gridItems) {
// ...
Sort.columnTitle("Market Value", ctx, \.marketValuePlaceholder)
.onTapGesture {
tablerSort(ctx, &model.holdings, \.marketValuePlaceholder) {
$0.getMarketValue(priceMap) < $1.getMarketValue(priceMap)
}
}
}
}
}
macOS | iOS |
---|---|
![]() |
![]() |
When used with 'bound' views (e.g., TablerListB
or TablerListC
), the data can be modified directly, mutating your data source. From the demo:
private func brow(fruit: BoundValue) -> some View {
LazyVGrid(columns: gridItems) {
Text(fruit.wrappedValue.id)
TextField("Name", text: fruit.name)
.textFieldStyle(.roundedBorder)
Text(String(format: "%.0f g", fruit.wrappedValue.weight))
ColorPicker("Color", selection: fruit.color)
.labelsHidden()
}
}
For value sources, BoundValue
is a binding:
typealias BoundValue = Binding<Fruit>
For reference sources, including Core Data, BoundValue
is an object wrapper (aka 'ProjectedValue'):
typealias BoundValue = ObservedObject<Fruit>.Wrapper
Note that for Core Data, the user's changes will need to be saved to the Managed Object Context. See the TablerCoreDemo code for an example of how this might be done.
You have the option to specify a row background, such as to impart information, or as a selection indicator.
Row Background, as the name suggests, sits BEHIND the row.
macOS | iOS |
---|---|
![]() |
![]() |
An example of using row background to impart information, as shown above:
var body: some View {
TablerList(header: header,
row: row,
rowBackground: rowBackground,
results: fruits)
}
private func rowBackground(fruit: Fruit) -> some View {
LinearGradient(gradient: .init(colors: [fruit.color, fruit.color.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom)
}
An example of a selection indicator using row background, such as for Stack based tables which do not have a native selection indicator:
@State private var selected: Fruit.ID? = nil
var body: some View {
TablerStack1(header: header,
row: row,
rowBackground: rowBackground,
results: fruits,
selected: $selected)
}
private func rowBackground(fruit: Fruit) -> some View {
RoundedRectangle(cornerRadius: 5)
.fill(fruit.id == selected ? Color.accentColor : Color.clear)
}
Similar to a row background, an overlay can be used to impart information, or to use as a selection indicator.
Row overlay, as the name suggests, sits ATOP the row.
An example of a selection indicator using row overlay:
@State private var selected: Fruit.ID? = nil
var body: some View {
TablerStack1(header: header,
row: row,
rowOverlay: rowOverlay,
results: fruits,
selected: $selected)
}
private func rowOverlay(fruit: Fruit) -> some View {
RoundedRectangle(cornerRadius: 5)
.strokeBorder(fruit.id == selected ? .white : .clear,
lineWidth: 2,
antialiased: true)
}
For macOS only, you can capture hover events, typically to highlight the row under the mouse cursor.
@State private var hovered: Fruit.ID? = nil
var body: some View {
TablerList(.init(onHover: hoverAction),
header: header,
row: row,
rowBackground: rowBackground,
results: fruits)
}
private func rowBackground(fruit: Fruit) -> some View {
RoundedRectangle(cornerRadius: 5)
.fill(Color.accentColor.opacity(hovered == fruit.id ? 0.2 : 0.0))
}
private func hoverAction(fruitID: Fruit.ID, isHovered: Bool) {
if isHovered { hovered = fruitID } else { hovered = nil }
}
To coordinate hover with other backgrounds, such as for selection on Stack tables, see the demo apps.
Row moving via drag and drop is available for the List based variants.
An example for use with Random Access Collections, as seen in TablerDemo:
var body: some View {
TablerList(.init(onMove: moveAction),
row: row,
results: fruits)
}
private func moveAction(from source: IndexSet, to destination: Int) {
fruits.move(fromOffsets: source, toOffset: destination)
}
TODO need Core Data example, if it's possible to do so.
var body: some View {
TablerList(.init(onMove: moveAction,
filter: { $0.weight > 10 },
onHover: hoverAction),
header: header,
row: row,
results: fruits)
}
Configuration options will vary by table type.
Defaults can vary by platform (macOS, iOS, etc.). See the code for specifics.
Spacing defaults are driven by the goal of achieving uniform appearance among table types, with the List type serving as the standard.
Base defaults are defined in the TablerConfig
module.
tablePadding: EdgeInsets
- no paddingsortIndicatorForward: AnyView
- "chevron.up" imagesortIndicatorReverse: AnyView
- "chevron.down" imagesortIndicatorNeutral: AnyView
- "chevron.up" image, with opacity of 0List configuration is optional.
TablerListConfig<Element>.init
parameters:
canMove: CanMove<Element>
- with a default of { _ in true }
, allowing any row to move (if onMove
defined)canDelete: CanDelete<Element>
- with a default of { _ in true }
, allowing any row to be deleted (if onDelete
defined), currently only via swipe menu on iOSonMove: OnMove<Element>?
- with a default of nil
, prohibiting any moveonDelete: OnDelete<Element>?
- with a default of nil
, prohibiting any delete, currently only via swipe menu on iOSfilter: Filter?
- with a default of nil
, indicating no filteringonHover: (Element.ID, Bool) -> Void
- defaults to { _,_ in }
tablePadding: EdgeInsets
- per Base defaultssortIndicatorForward: AnyView
- per Base defaultssortIndicatorReverse: AnyView
- per Base defaultssortIndicatorNeutral: AnyView
- per Base defaultsStack configuration is optional.
TablerStackConfig<Element>.init
parameters:
rowPadding: EdgeInsets
- Stack-specific defaults; varies by platformheaderSpacing: CGFloat
- default varies by platformfooterSpacing: CGFloat
- default varies by platformrowSpacing: CGFloat
- default of 0filter: Filter?
- with a default of nil
, indicating no filteringonHover: (Element.ID, Bool) -> Void
- defaults to { _,_ in }
tablePadding: EdgeInsets
- default varies by platformsortIndicatorForward: AnyView
- per Base defaultssortIndicatorReverse: AnyView
- per Base defaultssortIndicatorNeutral: AnyView
- per Base defaultsGrid configuration is required, where you supply a GridItem
array.
TablerGridConfig<Element>.init
parameters:
gridItems: [GridItem]
- requiredalignment: HorizontalAlignment
- LazyVGrid
alignment, with a default of .leading
itemPadding: EdgeInsets
- Grid-specific defaults, varies by platformheaderSpacing: CGFloat
- default varies by platformfooterSpacing: CGFloat
- default varies by platformrowSpacing: CGFloat
- default of 0filter: Filter?
- with a default of nil
, indicating no filteringonHover: (Element.ID, Bool) -> Void
- defaults to { _,_ in }
tablePadding: EdgeInsets
- default varies by platformsortIndicatorForward: AnyView
- per Base defaultssortIndicatorReverse: AnyView
- per Base defaultssortIndicatorNeutral: AnyView
- per Base defaultsOn compact displays you may wish to scroll the table horizontally.
You can wrap in your own ScrollView
, or alternatively import the SwiftSideways package:
import Tabler
import Sideways
var body: some View {
TablerList(header: header,
row: row,
results: fruits)
.sideways(minWidth: 400)
}
This applies only to those forking and customizing the Tabler code.
Many additional init()
functions for each table variant are generated via the code template Templates/AutoInit.stencil
.
To regenerate and re-format, run the Sourcery command from the project directory.
$ brew install sourcery
$ sourcery
$ brew install swiftformat
$ swiftformat **/*.swift
The generated code will be found in the Sources/Generated
directory.
Apps demonstrating Tabler:
This library is a member of the OpenAlloc Project.
Copyright 2021, 2022 OpenAlloc 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.
Contributions are welcome. You are encouraged to submit pull requests to fix bugs, improve documentation, or offer new features.
The pull request need not be a production-ready feature or fix. It can be a draft of proposed changes, or simply a test to show that expected behavior is buggy. Discussion on the pull request can proceed from there.
link |
Stars: 62 |
Last commit: 4 weeks ago |
For convenient swipe deletion on iOS.
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics