SwiftUI Table on iOS
SwiftUI offers a powerful Table view on macOS, while it is more limited on iOS. In this example the Table is used on iOS with special considerations for small and large screens.
For instance on small screens only the first table column is shown. Therefore the information of the other columns is included in the first column for these cases. As the table header is not clickable for sorting, the sorting feature is implemented without using Table's built-in sorting features.
import SwiftUI
struct ContentView: View {
struct Game: Identifiable, Hashable, Codable {
enum Console: String, CaseIterable, Codable, Hashable, Identifiable, Comparable {
static func < (lhs: Game.Console, rhs: Game.Console) -> Bool {
lhs.rawValue < rhs.rawValue
}
case ps5 = "PS5"
case xbox = "Xbox"
case nintendoSwitch = "Nintendo Switch"
var id: String { rawValue }
}
enum Owner: String, CaseIterable, Codable, Hashable, Identifiable, Comparable {
static func < (lhs: Game.Owner, rhs: Game.Owner) -> Bool {
lhs.rawValue < rhs.rawValue
}
case mother = "Mother"
case father = "Father"
case child1 = "Child 1"
case child2 = "Child 2"
var id: String { rawValue }
}
let id: UUID
let title: String
let console: Console
let owner: Owner
init(id: UUID = UUID(), title: String, console: Console, owner: Owner) {
self.id = id
self.title = title
self.console = console
self.owner = owner
}
}
@State private var games: [Game] = [
Game(title: "Mario Party", console: .ps5, owner: .mother),
Game(title: "Kirby", console: .xbox, owner: .father),
Game(title: "Zelda", console: .nintendoSwitch, owner: .child1),
Game(title: "Pikmin", console: .ps5, owner: .child2)
]
@State private var selectedConsole: Game.Console? = nil
@State private var selectedOwner: Game.Owner? = nil
@State private var sortBy: SortBy? = nil
@State private var searchText: String = ""
@State private var sortOrder: [KeyPathComparator < Game > ] = [
KeyPathComparator(\Game.title),
KeyPathComparator(\Game.console),
KeyPathComparator(\Game.owner),
]
enum SortBy: String, CaseIterable, Codable, Hashable, Identifiable {
case title = "Title"
case console = "Console"
case owner = "Owner"
var id: String { rawValue }
}
private var filteredAndSortedGames: [Game] {
var result = games
if let selectedConsole { result = result.filter { $0.console == selectedConsole } }
if let selectedOwner { result = result.filter { $0.owner == selectedOwner } }
if !searchText.isEmpty {
let q = searchText.lowercased()
result = result.filter { $0.title.lowercased().contains(q) }
}
if let sortBy {
switch sortBy {
case .title:
result.sort { lhs, rhs in
lhs.title < rhs.title
}
case .console:
result.sort { $0.console.rawValue < $1.console.rawValue }
case .owner:
result.sort { $0.owner.rawValue < $1.owner.rawValue }
}
}
return result
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("GameTracker").font(.largeTitle).bold()
.padding(.leading, 20)
controlBar
Group {
if UIDevice.current.userInterfaceIdiom == .phone {
Table(filteredAndSortedGames, sortOrder: $sortOrder) {
TableColumn("Game") { game in
VStack(alignment: .leading, spacing: 4) {
Text(game.title)
.font(.body)
Text(game.console.rawValue)
.foregroundStyle(.secondary)
.font(.caption)
Text(game.owner.rawValue)
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
} else {
Table(filteredAndSortedGames, sortOrder: $sortOrder) {
TableColumn("Title") { game in
Text(game.title)
}
TableColumn("Console") { game in
Text(game.console.rawValue)
}
TableColumn("Owner") { game in
Text(game.owner.rawValue)
}
}
}
}
}
.padding()
.searchable(text: $searchText, placement: .automatic, prompt: "Search titles")
}
private var controlBar: some View {
AdaptiveStack(hSpacing: 50, vSpacing: 20, alignment: .leading) {
VStack(alignment: .leading) {
Text("Sort by").font(.title2)
Menu {
Button("Unsorted") { sortBy = nil }
Divider()
ForEach(SortBy.allCases) { sortKey in
Button(sortKey.rawValue) { sortBy = sortKey }
}
} label: {
Label(sortBy?.rawValue ?? "Unsorted", systemImage: "arrow.up.arrow.down")
}
}
VStack(alignment: .leading) {
Text("Filter").font(.title2)
HStack(spacing: 20) {
// Console filter
Menu {
Button("All Consoles") { selectedConsole = nil }
Divider()
ForEach(Game.Console.allCases) { console in
Button(console.rawValue) { selectedConsole = console }
}
} label: {
Label(selectedConsole?.rawValue ?? "All Consoles", systemImage: "gamecontroller")
}
// Owner filter
Menu {
Button("All Owners") { selectedOwner = nil }
Divider()
ForEach(Game.Owner.allCases) { owner in
Button(owner.rawValue) { selectedOwner = owner }
}
} label: {
Label(selectedOwner?.rawValue ?? "All Owners", systemImage: "person")
}
}
}
}
.padding(.bottom, 20)
.padding(.leading, 20)
}
struct AdaptiveStack < Content: View > : View {
@Environment(\.horizontalSizeClass) private var hSize
@Environment(\.verticalSizeClass) private var vSize
let hSpacing: CGFloat
let vSpacing: CGFloat
let alignment: HorizontalAlignment
@ViewBuilder var content: () -> Content
private var isIPhonePortrait: Bool {
UIDevice.current.userInterfaceIdiom == .phone && hSize == .compact && vSize == .regular
}
var body: some View {
Group {
if isIPhonePortrait {
VStack(alignment: alignment, spacing: vSpacing) {
content()
}
} else {
HStack(spacing: hSpacing) {
content()
}
}
}
}
}
}
#Preview {
ContentView()
}