What I learned implementing this design in SwiftUI

What I learned implementing this design in SwiftUI

·

11 min read

Have you ever stumbled upon a design that instantly sparked your curiosity and prompted you to work into its recreation using your favorite tech stack? That’s precisely what happened with me after discovering Dribbble.com, a place that can be a great source of inspiration for any developer who doesn't really know what to build.

Dribbble's collection of UI/UX is huge, so decided to filter for apps and found this amazing work by Anastasia Golovko.

In this post, I’ll share my experiences, insights, and the lessons learned while exploring SwiftUI and implementing this app concept. Keep in mind that this is not a tutorial, but rather a log of my experience and thought process. That doesn't mean that by the end of this post, you won't have access to all the source code, though. If you want to just see it right away, here is a link to the repository.

Let's start 👨‍💻


Creating the models

Whenever I am starting a new SwiftUI project, the first things that I like create are the models that will be used. In this case, the fruits. I asked ChatGPT to help me with the custom background colors I was going to use, as well as the mock data for this project.

If you are willing to following along, don't forget to download some fruit images from the internet and add it into your assets folder with the correct names shown in the image attributes of the mock data.

//  FruitModel.swift
//  foodshop
//
//  Created by Pedro Caetano on 03/11/23.
//

import Foundation
import SwiftUI

struct Fruit: Hashable {
    let name: String
    let image: String
    let pricePerEach: Double
    let units: String
    let backgroundColor: UIColor
    let description: String

    init(name: String, image: String, pricePerEach: Double, units: String, backgroundColor: UIColor, description: String) {
        self.name = name
        self.image = image
        self.pricePerEach = pricePerEach
        self.units = units
        self.backgroundColor = backgroundColor
        self.description = description
    }
}

extension UIColor {
    static let strawberryRed = UIColor(red: 255/255, green: 89/255, blue: 89/255, alpha: 1.0)
    static let pineappleYellow = UIColor(red: 255/255, green: 221/255, blue: 51/255, alpha: 1.0)
    static let blueberryBlue = UIColor(red: 95/255, green: 169/255, blue: 243/255, alpha: 1.0)
    static let dragonFruitPink = UIColor(red: 255/255, green: 107/255, blue: 162/255, alpha: 1.0)
    static let lycheePink = UIColor(red: 252/255, green: 173/255, blue: 180/255, alpha: 1.0)
    static let mangoOrange = UIColor(red: 255/255, green: 165/255, blue: 2/255, alpha: 1.0)
    static let green = UIColor(red: 118/255, green: 215/255, blue: 196/255, alpha: 1.0)
    static let red = UIColor(red: 255/255, green: 84/255, blue: 112/255, alpha: 1.0)
    static let orange = UIColor(red: 255/255, green: 165/255, blue: 0, alpha: 1.0)
    static let yellow = UIColor(red: 255/255, green: 230/255, blue: 109/255, alpha: 1.0)
    static let purple = UIColor(red: 160/255, green: 92/255, blue: 240/255, alpha: 1.0)
    static let avocadoGreen = UIColor(red: 84/255, green: 130/255, blue: 53/255, alpha: 1.0)
}
let strawberry = Fruit(name: "Strawberries", image: "strawberry", pricePerEach: 0.5, units: "1 lb", backgroundColor: .strawberryRed, description: "Strawberries are a delicious and versatile fruit. They are known for their sweet and juicy taste, making them a perfect addition to desserts, smoothies, and salads. Strawberries are also rich in antioxidants and vitamin C, promoting overall health and well-being.")
let pineapple = Fruit(name: "Pineapples", image: "pineapple", pricePerEach: 2.99, units: "each", backgroundColor: .pineappleYellow, description: "Pineapples are a tropical delight, known for their sweet and tangy flavor. They are a refreshing fruit that can be enjoyed on its own or added to fruit salads and beverages. Pineapples are also a good source of vitamin C and manganese, promoting immune health and digestion.")
let blueberry = Fruit(name: "Blueberries", image: "blueberry", pricePerEach: 0.25, units: "1 lb", backgroundColor: .blueberryBlue, description: "Blueberries are small, plump, and bursting with flavor. They are packed with antioxidants, making them a superfood that supports brain health and may help reduce the risk of chronic diseases. Blueberries are perfect for adding to your morning cereal, pancakes, or as a topping for desserts.")
let dragonFruit = Fruit(name: "Dragon Fruits", image: "dragonfruit", pricePerEach: 4.99, units: "each", backgroundColor: .dragonFruitPink, description: "Dragon fruits are visually striking with their bright pink skin and white flesh speckled with tiny black seeds. They have a subtle, sweet flavor and are a great source of fiber and vitamin C. Dragon fruits are perfect for snacking, making smoothie bowls, or adding an exotic touch to your fruit platter.")
let lychee = Fruit(name: "Lychee", image: "lychee", pricePerEach: 1.0, units: "each", backgroundColor: .lycheePink, description: "Lychees are small, aromatic fruits with a sweet and floral flavor. They are commonly enjoyed fresh and are a good source of vitamin C and fiber. Lychees are a delightful treat, perfect for indulging in their unique taste and texture.")
let mango = Fruit(name: "Mangos", image: "mango", pricePerEach: 1.5, units: "each", backgroundColor: .mangoOrange, description: "Mangos are known for their sweet and tropical flavor. They are a rich source of vitamins A and C, promoting skin health and immune support. Mangos can be enjoyed on their own, added to salsas, or blended into refreshing smoothies.")
let kiwi = Fruit(name: "Kiwi", image: "kiwi", pricePerEach: 1.2, units: "each", backgroundColor: .green, description: "Kiwis are small but mighty, packed with vitamins and minerals. They have a tart, tangy flavor and are a great source of vitamin C and dietary fiber. Kiwis are a fantastic addition to fruit salads, desserts, and can even be eaten with the skin for added nutrition.")
let watermelon = Fruit(name: "Watermelon", image: "watermelon", pricePerEach: 5.99, units: "each", backgroundColor: .red, description: "Watermelons are a classic summer favorite, known for their sweet and juicy flesh. They are incredibly hydrating and a good source of vitamins A and C. Watermelons are perfect for staying refreshed on hot days and are a delightful addition to picnics and BBQs.")
let orange = Fruit(name: "Oranges", image: "orange", pricePerEach: 0.75, units: "each", backgroundColor: .orange, description: "Oranges are a popular citrus fruit celebrated for their sweet and tangy flavor. They are an excellent source of vitamin C, providing a natural immune boost. Oranges are ideal for fresh orange juice, snacking, and adding a burst of citrus to your dishes.")
let apple = Fruit(name: "Apples", image: "apple", pricePerEach: 0.8, units: "each", backgroundColor: .red, description: "Apples are a classic fruit enjoyed worldwide. They come in various varieties, offering a range of flavors from sweet to tart. Apples are a good source of dietary fiber and vitamin C. They are perfect for snacking, making pies, or adding to salads.")
let banana = Fruit(name: "Bananas", image: "banana", pricePerEach: 0.35, units: "each", backgroundColor: .yellow, description: "Bananas are a popular and versatile fruit known for their creamy texture and natural sweetness. They are an excellent source of potassium, essential for heart health. Bananas are perfect for smoothies, as a quick snack, or adding natural sweetness to your baked goods.")
let grape = Fruit(name: "Grapes", image: "grape", pricePerEach: 2.5, units: "1 lb", backgroundColor: .purple, description: "Grapes are small, sweet, and come in various colors, including red, green, and black. They are a rich source of antioxidants, promoting heart health and overall well-being. Grapes are a convenient and healthy snack, and they make a lovely addition to fruit platters.")
let avocado = Fruit(name: "Avocado", image: "avocado", pricePerEach: 1.99, units: "each", backgroundColor: .avocadoGreen, description: "Avocados are known for their creamy, buttery texture and mild flavor. They are a nutrient powerhouse, rich in healthy fats, fiber, and vitamins. Avocados are ideal for making guacamole, adding to sandwiches, and incorporating into a wide range of dishes.")

let mockFruitData: [Fruit] = [
    strawberry, pineapple, blueberry, dragonFruit, lychee, mango, kiwi, watermelon, orange, apple, banana, grape, avocado
]

Implementing the views

ContentView

In my content view, I have implemented a navigation stack that wraps my entire Home Screen. The reason why I took this decision is because the users should be able to click on the individual fruit cards present inside of the FruitGrid component and navigate into them to see more details.

When I was first implementing this part, I noticed an interest behavior from the NavigationStack, and learned that:

The navigation stack acts as a ‘’Portal’’ within your app's architecture, maintaining the consistent surroundings while dynamically swapping out the content enclosed within it. Clicking on a navigation link triggers this transition, seamlessly updating the displayed content while preserving the overall app structure and elements surrounding it.

I also implemented my own search bar and created a State variable for the searched element. If you are not exactly sure what State or Binding is, don't forget to check out my other blog post explaining the differences between them.

Underneath the search bar, we have the FruitGrid, that will show all of the items we have.

//  ContentView.swift
//  foodshop
//
//  Created by Pedro Caetano on 03/11/23.

import SwiftUI

struct ContentView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @State var searchText: String = ""

    var body: some View {
        NavigationStack {
            VStack{
                VStack(alignment: .leading) {
                    Text("Fruits and berries")
                        .font(.title)
                        .fontWeight(.bold)
                    SearchBarView(searchText: $searchText)
                }
                .padding(.bottom)

                FruitGrid(searchText: $searchText)
            }
            .padding(.horizontal)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    @State static var search: String = ""
    static var previews: some View {
        ContentView(searchText: search)
    }
}

SearchBarView

This view is very simple. The search bar receives the search text as Binding and associates what is typed inside of the text field with it.

//  SearchBarView.swift
//  foodshop
//
//  Created by Pedro Caetano on 03/11/23.
//

import SwiftUI

struct SearchBarView: View {
    @Binding private var searchText: String

    init(searchText: Binding<String>) {
        _searchText = searchText
    }
    var body: some View {
        HStack{
            Image(systemName: "magnifyingglass")
            TextField(
                "Search",
                text: $searchText
            )
        }.padding()
            .background(Color.inputGray)
            .clipShape(RoundedRectangle(cornerRadius: 15))
    }
}

struct SearchBarView_Previews: PreviewProvider {
    @State static var search: String = ""

    static var previews: some View {
        SearchBarView(searchText: $search)
    }
}

FruitGridView

The fruit grid also receives the search text as Binding, but this time, we use it to filter our items.

You might now be wondering what a WaterfallGrid is, and I'll explain:

The WaterfallGrid is a third party component that implements a grid similar to the one we see in apps like Pinterest. I have tried creating my own component that would behave in a similar fashion, but I found it to be too much work for something that had already been implemented.

Inside of the grid, we use navigation links to direct the user to the element he chooses.

//  FruitGrid.swift
//  foodshop
//
//  Created by Pedro Caetano on 04/11/23.
//

import SwiftUI
import WaterfallGrid

struct FruitGrid: View {
    @Binding var searchText: String

    var body: some View {
        ScrollView {
            WaterfallGrid(mockFruitData.filter { fruit in
                searchText.isEmpty || fruit.name.localizedCaseInsensitiveContains(searchText)
            }, id: \.self) { fruit in
                NavigationLink(destination: FruitDetailView(fruit: fruit)) {
                    FruitCardView(fruit: fruit)
                        .foregroundStyle(Color.black)
                }
            }
            .gridStyle(
                columnsInPortrait: 2,
                columnsInLandscape: 3,
                spacing: 15
            )
        }
    }
}

struct FruitGrid_Previews: PreviewProvider {
    @State static var search: String = ""

    static var previews: some View {
        FruitGrid(searchText: $search)
    }
}

FruitCardView

This component is the individual fruit card shown in the grid. It receives data from a fruit passed into it and displays its information.

The trickiest part about this component was to create that little selection toggle that has only two rounded edges. After some research, I discovered that we can change the individual borders of a rectangle reproduce the same result within a clipshape.

.clipShape(.rect(
            topLeadingRadius: 20,
            bottomLeadingRadius: 0,
            bottomTrailingRadius: 20,
            topTrailingRadius: 0
          ))

and here is the full component:

//  FruitCardView.swift
//  foodshop
//
//  Created by Pedro Caetano on 03/11/23.
//

import SwiftUI

struct FruitCardView: View {
    let fruit: Fruit

    var body: some View {
        VStack {
            VStack(alignment: .leading){
                Text(fruit.name)
                    .font(.headline)
                    .fontWeight(.semibold)

                Text(fruit.units)
                    .font(.subheadline)
                    .opacity(0.8)

                Text(String(format: "$%.2f", fruit.pricePerEach))
                    .font(.headline)
                    .fontWeight(.bold)

                Image(fruit.image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 120)

            }
            .padding(.top, 20)
            HStack{
                Spacer()
                Image(systemName: "checkmark")
                    .foregroundStyle(Color.white)
                    .fontWeight(.bold)
                    .padding(15)
                    .background(Color(fruit.backgroundColor).opacity(0.3))
                    .clipShape(.rect(
                        topLeadingRadius: 20,
                        bottomLeadingRadius: 0,
                        bottomTrailingRadius: 20,
                        topTrailingRadius: 0
                    ))

            }
        }
        .background(Color(fruit.backgroundColor).opacity(0.2))
        .clipShape(RoundedRectangle(cornerRadius: 30))
    }
}

#Preview {
    FruitCardView(fruit:strawberry)
}

And now we have our Home Screen 🎉

sc1.png

FruitDetailView

Having the Home Screen finished (no, I didn't implement the header), we can finally go to the details page. My component ended up too big, but I can break it into smaller pieces if I want.

This view displays the item the user has selected along with its information and a selector for how many items he wants to buy. Here again, we use a State variable to change the UI when the value mutates.
I have customized the toolbar some icons in and remove the default navigation button.
At the end of the component, we have a view that holds the heart and the "add to cart" button.

//  FruitDetailView.swift
//  foodshop
//
//  Created by Pedro Caetano on 04/11/23.
//

import SwiftUI

struct FruitDetailView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @State var fruitCount: Int = 1
    let fruit: Fruit
    var body: some View {
        VStack{
            GeometryReader{ geometry in
                HStack{
                    Image(fruit.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: geometry.size.width, height: geometry.size.height)
                }
            }
            Spacer()
            VStack{
                HStack{
                    VStack(alignment: .leading){
                        Text(fruit.name)
                            .font(.title)
                            .fontWeight(.bold)
                        Text(fruit.units)
                            .font(.subheadline)
                            .opacity(0.5)
                    }

                    Spacer()
                }
                .padding(.top,30)
                HStack{
                    HStack{
                        Button(action: {(fruitCount>1) ? (fruitCount-=1) : (fruitCount=1)}, label: {
                            Text("-")
                                .font(.title2)
                                .foregroundStyle(Color.black)
                        })
                        .padding()

                        Text("\(fruitCount)")
                            .padding()
                            .font(.title2)
                            .foregroundStyle(Color.black)

                        Button(action: {fruitCount+=1}, label: {
                            Text("+")
                                .font(.title2)
                                .foregroundStyle(Color.black)
                        })
                        .padding()

                    }
                    .background(Color.inputGray)
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                    Spacer()
                    Text(String(format: "$%.2f", fruit.pricePerEach))
                        .font(.title)
                        .fontWeight(.bold)
                }
                VStack(alignment: .leading){
                    Text("Product Description")
                        .font(.title2)
                        .fontWeight(.semibold)
                        .padding(.bottom)
                    Text(fruit.description)
                        .fixedSize(horizontal: false, vertical: true)
                }
                ButtonsView(fruit: fruit)
            }
            .frame(maxHeight: .infinity)
            .padding(.horizontal, 40)
            .padding(.bottom, 50)
            .background(Color.white)
            .clipShape(RoundedRectangle(cornerRadius: 65))
            .offset(CGSize(width: 0, height: 40.0))
        }
        .navigationBarBackButtonHidden(true)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button(action: {
                        self.presentationMode.wrappedValue.dismiss()
                    }) {
                        Image(systemName: "chevron.backward.square.fill")
                            .font(.title)
                            .foregroundStyle(Color.black)
                    }
                } 
                ToolbarItem(placement: .topBarTrailing) {
                    Button(action: {
                        self.presentationMode.wrappedValue.dismiss()
                    }) {
                        Image(systemName: "line.horizontal.3.decrease.circle")
                            .font(.title)
                            .foregroundStyle(Color.black)
                    }
                }
            }
        .background(Color(fruit.backgroundColor))
    }
}

#Preview {
    FruitDetailView(fruit: banana)
}

ButtonsView

These are the two buttons displayed in the details view. I took advantage of the fact that SwiftUI takes in consideration the order of the modifiers you pass to the elements to create the background effects and apply the correct paddings.

//  ButtonsView.swift
//  foodshop
//
//  Created by Pedro Caetano on 05/11/23.
//

import SwiftUI

struct ButtonsView: View {
    let fruit: Fruit
    var body: some View {
        HStack{
            Button(action: {}, label: {
                Image(systemName: "heart.fill")
                    .font(.largeTitle)
                    .foregroundStyle(Color(fruit.backgroundColor))
            })
            .frame(width: 70, height: 70)
            .background(Color.clear)
            .clipShape(Circle())
            .overlay(
                RoundedRectangle(cornerRadius: 15.0)
                    .stroke(Color(fruit.backgroundColor), lineWidth: 1)
            )
            .padding(.trailing, 5)

            Button(action:/{}, label: {
                Text("Add to cart")
                    .foregroundStyle(Color.black)
                    .font(.headline)
                    .fontWeight(.bold)
            })
            .frame(width: 250, height: 70)
            .background(Color(fruit.backgroundColor))
            .clipShape(RoundedRectangle(cornerRadius: 15.0))

        }
    }
}

#Preview {
    ButtonsView(fruit: mango)
}

and we have the full detail view:

sc2.png

Conclusion

With all these components together, we have our app finished and working. Of course, it is not pixel perfect, but it is a great exercise to learn swiftUI fundamentals.

If you have read this until the end, thank you to much! I can't wait to learn more about SwiftUI and bring more content to this blog.

If you want the source code, you can find it here.

Goodbye! 👋