SwiftUI - grid / table with column headings - swiftui

I'm trying to achieve a design which essentially has the following layout:
So I need column headings for this table or grid view. The second column does not need a heading.
This seems like a job for a LazyVGrid or LazyHGrid, however I can't seem to find a solution which neatly includes headings like this - it all seems a bit hacky.
Wondering if I am missing a better way to achieve this. I could of course try and create VStacks within HStacks, but this just seems like something which should be accomplished more intelligently than this.

You can just pass the headers into the LazyVGrid before you show the content:
struct Item: Identifiable {
let id = UUID()
var item: String
var description: String = "This is the item description"
var quantity: Int = 1
var price: Double = 0
}
struct ContentView: View {
let data = [
Item(item: "Image 1", quantity: 2, price: 1.99),
Item(item: "Image 2", quantity: 1, price: 3.99),
Item(item: "Image 3", quantity: 5, price: 9.99),
]
let columns = [
GridItem(.flexible(), alignment: .topLeading),
GridItem(.flexible(minimum: 150), alignment: .topLeading),
GridItem(.flexible(), alignment: .topLeading),
GridItem(.flexible(), alignment: .topLeading),
]
var body: some View {
LazyVGrid(columns: columns) {
// headers
Group {
Text("Item")
Text("")
Text("Qty")
Text("Price")
}
.font(.headline)
// content
ForEach(data) { item in
Text(item.item)
Text(item.description)
Text("\(item.quantity)")
Text("$\(item.price, specifier: "%.2f")")
}
}
.padding()
}
}

Related

How can I display tabular data with SwiftUI on iPhone?

I'm working on an app for my local sports league.
One view will be the current standings, with several fields on each row: Name of team, Games Played, Points, etc. I want the Teams column to be left-aligned, the other columns to be right-aligned.
It seems the best answer is SwiftUI's Table(), but on iPhone, it only displays the first column.
I've tried using an HStack{} with Text():
List {
ForEach(selectedStats()) { stats in
HStack {
Text (stats.name)
Spacer()
Text ("\(stats.totalMatches)")
Text ("\(stats.standingsPoints)")
}
}
}
but I'm having difficulty getting the columns to align left or right across multiple lines in the table.
Is there a way to get Tables to work? Or how can I align my columns?
(P. S. I used the tag TableView, because there is no tag specific to Table or SwiftUI-Table. It would be nice if that could be added.)
SwiftUI's Grid and GridRow are your friends for laying out simple content like this:
struct Stat: Identifiable, Equatable {
var id: String { name }
let name: String
let totalMatches: Int
let standingsPoints: Int
}
struct ContentView: View {
let stats = [
Stat(name: "Fred", totalMatches: 12, standingsPoints: 87),
Stat(name: "Jim", totalMatches: 4, standingsPoints: 12),
Stat(name: "Dave", totalMatches: 9, standingsPoints: 91)]
var body: some View {
List {
Grid {
GridRow {
Text("Name")
Text("Matches")
Text("Points")
}
.bold()
Divider()
ForEach(stats) { stat in
GridRow {
Text(stat.name)
Text(stat.totalMatches, format: .number)
Text(stat.standingsPoints, format: .number)
}
if stat != stats.last {
Divider()
}
}
}
}
}
}
You can tweak the layout by adding Spacer()s .gridCellAnchor modifiers, e.g.
var body: some View {
List {
Grid {
GridRow {
Text("Name"). // UnitPoint(x: 1, y: 0.5) = align right
.gridCellAnchor(UnitPoint(x: 1, y: 0.5))
Spacer()
Text("Matches") // UnitPoint(x: 0, y: 0.5) = align left
.gridCellAnchor(UnitPoint(x: 0, y: 0.5))
Text("Points")
.gridCellAnchor(UnitPoint(x: 0, y: 0.5))
}
.bold()
Divider()
ForEach(stats) { stat in
GridRow {
Text(stat.name)
.gridCellAnchor(UnitPoint(x: 1, y: 0.5))
Spacer()
Text(stat.totalMatches, format: .number)
.gridCellAnchor(UnitPoint(x: 0, y: 0.5))
Text(stat.standingsPoints, format: .number)
.gridCellAnchor(UnitPoint(x: 0, y: 0.5))
}
if stat != stats.last {
Divider()
}
}
}
}
}
I kept researching after I posted and came across some stuff by Paul Hudson of Hacking with Swift fame.
Alignment and alignment guides
How to create a custom alignment guide
Based on those two, I came up with this:
extension HorizontalAlignment {
enum GP: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.trailing]
}
}
static let gp = HorizontalAlignment(GP.self)
}
and then I apply
.alignmentGuide(.gp) { d in d[HorizontalAlignment.trailing] }
to each element I want to right-align.
Seems to do the trick, as well.

Adding Shadow to LazyHGrid Element Selection

I have an app in which the user may select a SF icon from those within a LazyHGrid. I would like to add a shadow around the selected icon and remove the shadow when deselected.
Currently, the working code below may be used to scroll the available icons and select an icon by tapping. I need help changing the view to support applying shadow to the selected element.
I tried placing the same image() and modifiers within the button action but got a Xcode warning that the ZStack initializer is unused. I also tried adding a shadow modifier to the view changing the shadow parameters with state properties set in the button action area. This applied shadow to all elements in LazyHGrid. I want the shadow applied only to the selected element.
struct ImageStore: Identifiable, Hashable {
var iconName: String
var id: Int
}
struct ContentView: View {
let rows = [
GridItem(.flexible()),
]
let colors: [Color] = [.green, .red, .yellow, .blue]
let imageName = [
ImageStore(iconName: "a.square.fill", id: 0),
ImageStore(iconName: "b.square.fill", id: 1),
ImageStore(iconName: "c.square.fill", id: 2),
ImageStore(iconName: "d.square.fill", id: 3),
ImageStore(iconName: "e.square.fill", id: 4),
ImageStore(iconName: "f.square.fill", id: 5),
ImageStore(iconName: "g.square.fill", id: 6),
]
#State private var selectedIcon: Int = 0
var body: some View {
VStack {
ScrollView (.horizontal) {
LazyHGrid( rows: rows, spacing: 20) {
ForEach(imageName, id: \.self) { image in
Button( action: {
selectedIcon = image.id
print("image name = \(image.iconName)")
print("id = \(image.id)")
print("selectedIcon = \(selectedIcon)")
}){
Image(systemName: image.iconName)
.font(.largeTitle)
.foregroundColor(.white)
.padding()
.background(colors[image.id % colors.count])
}
}
}
}
}
}
}
Perhaps I'm not understanding your question fully, but it should be as simple as using the .shadow modifier with a ternary expression, e.g.
.shadow(radius: selectedIcon == image.id ? 5 : 0)
to make sure the image doesn't have it's own shadow in addition to the background, add a .drawingGroup modifier, e.g
Button {
selectedIcon = image.id
} label: {
Image(systemName: image.iconName)
.font(.largeTitle)
.foregroundColor(.white)
.padding()
.background(colors[image.id % colors.count])
.drawingGroup()
.shadow(radius: selectedIcon == image.id ? 5 : 0)
}

How to tap on a grid item in a LazyVGrid and segue to another view (Essentially like a collection view)

I have my code set as follows:
var body: some View {
NavigationStack {
VStack {
// Contacts Scroll View
ScrollView {
LazyVGrid(columns: threeColumnGrid, spacing: 20) {
ForEach($contacts, id: \.self) { $contact in
ContactCell(firstName: $contact.firstName.wrappedValue)
}
}.padding(EdgeInsets(top: 20,
leading: 20,
bottom: 20,
trailing: 20))
}
}.background(Color(CustomColors.background.rawValue))
}
}
I would like to be able to tap on one of the grid items in order to segue into another screen, but the only solution I can come up with is NavigationLink which only inserts a link that needs to be tapped.
I need the entire grid item to be tappable without any extra text acting as a link.
Side note: I have also looked into the isActive property of NavigationLink, which worked great, but this is being deprecated in iOS 16... It's as if Apple refuses to allow us to create a collection view using swiftUI.
Figured it out. I used: navigationDestination(isPresented:destination:)
See code below:
struct ContactsGridView: View {
#Binding var contacts: [Contact]
#State var shouldPresentContactMainView = false
let threeColumnGrid = [GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20)]
var body: some View {
NavigationStack {
VStack {
// Contacts Scroll View
ScrollView {
LazyVGrid(columns: threeColumnGrid, spacing: 20) {
ForEach($contacts, id: \.self) { $contact in
ContactCell(firstName: $contact.firstName.wrappedValue)
.onTapGesture {
shouldPresentContactMainView = true
}
.navigationDestination(isPresented: $shouldPresentContactMainView) {
ContactMainView()
}
}
}.padding(EdgeInsets(top: 20,
leading: 20,
bottom: 20,
trailing: 20))
}
}.background(Color(CustomColors.background.rawValue))
}
}
}

Align Subtitles and Footers with LazyVGrid?

I have a question about using LazyVGrid to align the subtitles and footers for each column. Say I have a view like this:
Title
Subtitle A Subtitle B Subtitle C
row1A row1B row1C
row2A row2B row2C
row3A row3B row3C
row4A row4B row4C
row5A row5B row5C
Total A Total B Total C
Currently I use Geometry Reader to display the headers and footers and LazyVGrid to display the data. I have noticed that if I line up the subtitles with a 12 ProMax simulator then view my setup on the 12 Mini simulator that the Mini titles have moved and don't appear very professional. Is it possible to use something like the following with LazyVGrid to not only display the data but also the subtitles and footers? Using pinnedViews: [] I can display the first column header, but how do I display all three subtitles and footers? There is some Apple documentation here on using section headers but nothing on multiple section headers and footers.
private var columns: [GridItem] = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
GeometryReader { g in
ScrollView {
VStack (alignment: .leading) {
ShowTitle()
ShowSubTitle()
LazyVGrid(
columns: columns,
alignment: .leading,
spacing: 10, // vertical spacing
pinnedViews: [.sectionHeaders, .sectionFooters]
) {
Section(header: Text("Subtitle A")
.font(.headline)) {
ForEach(0..<self.categories.catItem.count, id: \.self) { index in
ShowRow(index: index)
}
}
}
I mocked this up off of the Apple example at Grouping Data with Lazy Stack Views. The trick is to put the LazyVStack inside of the ForEach that goes through each section like this:
struct LazyVGridSectioned: View {
let sections = [
ColorData(color: .red, name: "Reds", footerInfo: "Red Footer Info"),
ColorData(color: .green, name: "Greens", footerInfo: "Green Footer Info"),
ColorData(color: .blue, name: "Blues", footerInfo: "Blue Footer Info")
]
var body: some View {
ScrollView {
HStack {
ForEach(sections) { section in
LazyVStack(spacing: 1, pinnedViews: [.sectionHeaders, .sectionFooters]) {
Section(header: SectionHeaderView(colorData: section), footer: SectionFooterView(colorData: section)) {
ForEach(section.variations) { variation in
section.color
.brightness(variation.brightness)
.frame(height: 20)
}
}
}
}
}
}
}
}
struct SectionHeaderView: View {
var colorData: ColorData
var body: some View {
HStack {
Spacer()
Text("Header for \(colorData.name)")
.font(.headline)
.foregroundColor(colorData.color)
Spacer()
}
.padding()
.background(Color.primary
.colorInvert()
.opacity(0.75))
}
}
struct SectionFooterView: View {
var colorData: ColorData
var body: some View {
HStack {
Spacer()
Text(colorData.footerInfo)
.font(.headline)
.foregroundColor(colorData.color)
Spacer()
}
.padding()
.background(Color.primary
.colorInvert()
.opacity(0.75))
}
}
struct ColorData: Identifiable {
let id = UUID()
let name: String
let footerInfo: String // Add parameter in ColorData
let color: Color
let variations: [ShadeData]
struct ShadeData: Identifiable {
let id = UUID()
var brightness: Double
}
// Add it to the init as well
init(color: Color, name: String, footerInfo: String) {
self.name = name
self.color = color
self.footerInfo = footerInfo // Assign it here
self.variations = stride(from: 0.0, to: 0.5, by: 0.1)
.map { ShadeData(brightness: $0) }
}
}

How to recreate the grid (in screenshot) in SwiftUI without breaking navigation?

I am trying to recreate a layout similar to the Reminders app. Looking at it makes me think it was built with SwiftUI. I also believe Apple mentioned so in one of the WWDC videos (can't remember which one).
This above screenshot seems to be a List, with a LazyVGrid as the first View inside the List. Tapping on each of the items in the LazyVGrid, such as Today, Scheduled, All and Flagged, navigates to the relevant screen, which means they are all NavigationLinks. Also note that the LazyVGrid has 2 columns.
And then there is another section "My Lists" which has rows which look like regular list rows in a List with style .insetGrouped. Also, every item in this Section is a NavigationItem, and thus comes with the disclosure indicator on the right as usual. Recreating this is trivial, so it has been left out from the MRE.
I am having trouble recreating the first section, which has that LazyVGrid. I faced 3 problems (as mentioned in the image), of which I have been able to solve the first one only. The other two problems remain. I want to know if this MRE can be fixed, or is my entire approach incorrect.
I am including a minimum reproducible example below.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
RemindersView()
}
}
}
struct RemindersView: View {
private var columns: [GridItem] = [GridItem(.adaptive(minimum: 150))]
private var smartLists: [SmartList] = SmartList.sampleLists
var body: some View {
NavigationView {
List {
Section(header: Text("Using LazyVGrid")) {
grid
}
Section(header: Text("Using HStack")) {
hstack
}
}
.navigationTitle("Store")
}
.preferredColorScheme(.dark)
}
private var grid: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(smartLists) { smartList in
// This use of **ZStack with an EmptyView with opacity 0** is a hack being used to avoid the disclosure indicator on each item in the grid
ZStack(alignment: .leading) {
NavigationLink( destination: SmartListView(list: smartList)) {
EmptyView()
}
.opacity(0)
SmartListView(list: smartList)
}
}
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
private var hstack: some View {
ScrollView(.horizontal) {
HStack {
ForEach(smartLists) { smartList in
NavigationLink(destination: SmartListView(list: smartList)) {
SmartListView(list: smartList)
}
.buttonStyle(.plain)
}
}
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
struct RemindersView_Previews: PreviewProvider {
static var previews: some View {
RemindersView()
}
}
struct SmartList: Identifiable {
var id: UUID = UUID()
var title: String
var count: Int
var icon: String
var iconColor: Color
static var sampleLists: [SmartList] {
let today = SmartList(title: "Today", count: 5, icon: "20.circle.fill", iconColor: .blue)
let scheduled = SmartList(title: "Scheduled", count: 12, icon: "calendar.circle.fill", iconColor: .red)
let all = SmartList(title: "All", count: 77, icon: "tray.circle.fill", iconColor: .gray)
let flagged = SmartList(title: "Flagged", count: 5, icon: "flag.circle.fill", iconColor: .orange)
return [today, scheduled, all, flagged]
}
}
struct SmartListView: View {
var list: SmartList
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center) {
Image(systemName: list.icon)
.renderingMode(.original)
.font(.title)
.foregroundColor(list.iconColor)
Spacer()
Text("\(list.count)")
.font(.system(.title, design: .rounded))
.fontWeight(.bold)
.padding(.horizontal, 8)
}
Text(list.title)
.font(.system(.headline, design: .rounded))
.foregroundColor(.secondary)
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.gray.opacity(0.25))
)
.padding(2)
.frame(minWidth: 150)
}
}
EDIT 1: Adding video demo of what editing the dynamic Grid looks like and how the Grid has dynamic grid items (via the Edit button at the top right): https://imgur.com/a/TV0kifY