Time Zones App Part 1: User interface

Swift UI - Time Zone App Nov 21, 2019

This is a follow-up article of what I wrote about last week. Remember the watch interface we created, the one I said was a small piece from a relatively bigger project? If you haven’t read that , here it is Analogue watch design. You need that piece if you will be following along.

Here is what we are going to build.

The idea behind this app

This app is supposed to help people keep track of different time zones. The user will have a list of time zones fetched by making a rest API call from which she or he can choose from and save them locally using CoreData.

Let’s get started. You can continue with the previous analogue watch design project or start with a brand-new one.If you choose the former option, you will just need to copy a few files from the project. Here is the completed project

Let’s start by creating a few files.

Models

Create a folder named Models and inside it add a swift file named TimeZone, then add the following code inside:

import Foundation

struct TimeZone: Identifiable {
    let id: UUID
    let Country: String
    let City: String
    let time: String
    
    static func data() -> [TimeZone] {
        return [
            TimeZone(id: UUID(), Country: "USA", City: "Chicago", time: "7:00 AM"),
            TimeZone(id: UUID(), Country: "Croatia", City: "Zagreb", time: "5:00 AM"),
            TimeZone(id: UUID(), Country: "Ireland", City: "Limerick", time: "15:00 AM"),
            TimeZone(id: UUID(), Country: "Burundi", City: "Bujumbura", time: "10:00 AM"),
        ]
    }
}

This struct will be changed in a later article when we integrate CoreData in the project. However, right now we will just use that struct for demo purposes .

Shapes

This is where ,we will be keeping our custom shapes. Create folder named Shapes containing a file named TopRoundedShape with the following content inside:

import SwiftUI


struct TopRoundedShape: Shape {
    var cornerRadius:CGFloat = 40
    
    var animatableData: CGFloat{
        get {
            cornerRadius
        }
        
        set{
            cornerRadius = newValue
        }
    }
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to:  CGPoint(x: 0, y: cornerRadius))
        path.addQuadCurve(to: CGPoint(x: cornerRadius, y: 0), control: CGPoint.zero)
        path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: 0))
        path.addQuadCurve(to: CGPoint(x: rect.width, y: cornerRadius), control: CGPoint(x: rect.width , y: 0))
        path.addLine(to: CGPoint(x: rect.width, y: rect.height))
        path.addLine(to: CGPoint(x: 0, y: rect.height))
        path.closeSubpath()

        return path
    }
}

This above code creates a rectangle shape that has its top corners rounded.

Views

Create a Views folder and download the analogue watch design project here (if you haven’t done that already) then drag and drop into the folder the following files WatchFrame.swift, WatchHand.swift and WatchView .swift. Next, create a new swift file named TimeZoneView and inside it, add the following code:


import SwiftUI

struct TimeZoneView: View {
    var timeZone = TimeZone.data().first!
    
    var body: some View {
        HStack{
            
            VStack(alignment: .leading) {
                Text(timeZone.Country).font(.system(size: 15, weight: Font.Weight.black))
                Text(timeZone.City).foregroundColor(Color.gray)
            }
            Spacer()
            Text(timeZone.time).font(.system(size: 20, weight: Font.Weight.black))
        }.frame(maxWidth: .infinity)
            .padding(30)
            .background(Color.white)
            .cornerRadius(20)
            .shadow(color: Color.gray.opacity(0.2), radius: 8, x: 0, y: 8)
            .padding(.horizontal)
    }
}

struct TimeZoneView_Previews: PreviewProvider {
    static var previews: some View {
        TimeZoneView()
    }
}

The above code doesn’t do anything special, it just creates the following:

Inside the same folder, add a new file named AddedTimeZoneView with the following code inside:

 import SwiftUI

struct AddedTimeZoneView: View {
    var timeZone = TimeZone.data().first!
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(timeZone.Country).font(.system(size: 15, weight: Font.Weight.black))
            Text(timeZone.City).foregroundColor(Color.gray)
        }.padding()
            .background(Color.white)
            .cornerRadius(20)
            .shadow(color: Color.gray.opacity(0.2), radius: 8, x: 0, y: 0)
    }
}

struct AddedTimeZoneView_Previews: PreviewProvider {
    static var previews: some View {
        AddedTimeZoneView()
    }
}



The above code does the same thing as the previous one, and here is the preview:

The remaining work will be done in the ContentView.swift file.

Open that file and put this at the top of the file:

 @State var isExpanded: Bool  = false
 private let headerHeight: CGFloat = 100

Let’s now create one part at a time from top to bottom.

In the body block, replace everything with the following:

VStack {
        if !isExpanded {
           Header().transition(.move(edge: .leading))
        }
}.edgesIgnoringSafeArea(.bottom)

Then outside the ContentView struct, add this:

struct Header: View {
    var headerHeight: CGFloat = 100

    var body: some View {
        VStack{
            HStack{
                Spacer()
                Image(systemName: "person.crop.circle")
                    .resizable()
                    .frame(width: 30, height: 30)
                    .padding(.horizontal)
            }
            Text("Burundi")
            Text("Sun, 10 Nov 2019")
        }.frame(height: self.headerHeight)
    }
}

Preview

Swiift UI
Swiift UI

WatchView

Underneath the header, add the following code:

            if !isExpanded{
                WatchView(diameter: 170).transition(.scale)
            }

Notice the transition modifier with a .scale value, this will make the WatchView scale up to its normal size (which is what you are seeing right ) whenever the isExpanded is set to false or scale down and disappear otherwise.

Preview

SwiftUI - WatchView  Layout
SwiftUI - WatchView Layout

SavedTimeZones

This view will show saved time zones. So underneath WatchView, add this :

            if !isExpanded{
                SavedTimeZones().transition(.move(edge: .leading))
            }

Then this below Header:

struct SavedTimeZones: View {
    var body: some View {
        ScrollView( .vertical, showsIndicators: false) {
            VStack {
                ForEach(TimeZone.data()){ timeZone in
                    TimeZoneView(timeZone: timeZone)
                }
            }
        }
    }
}

SwiftUI - Saved Time zones
SwiftUI - Saved Time zones

BottomToolbar

Underneath SavedTimeZones, add this :

 BottomNavBar()

Then this below SavedTimeZones :

struct BottomToolbar: View {
    var body: some View {
        HStack{
            Image(systemName: "square.grid.2x2").resizable().frame(width: 20, height: 20)
            Spacer()
            Image(systemName: "alarm").resizable().frame(width: 20, height: 20)
            Spacer()
            Image(systemName: "person").resizable().frame(width: 20, height: 20)
            Spacer()
        }.padding(30).frame(maxWidth: .infinity, alignment: .top).background(Color.white)
            .clipShape(TopRoundedShape(cornerRadius: 30))
            .shadow(color: Color.gray.opacity(0.2), radius: 10, x: 0, y: -5)
    }
}

Preview

SwiftUI-BottomNavBar
SwiftUI-BottomNavBar

Now that we have all of those small parts out of the way, let’s work on the coolest part of this article which will also be small but cool nonetheless .

Add this modifier on the BottomNavBar:

 .overlay(
                    ZStack(alignment: .bottom)  {
                        HStack{
                            if self.isExpanded{
                                VStack {
                                    Text("Hello world")
                                }
                            }
                        }.frame(width: self.isExpanded ? UIScreen.main.bounds.width : 50, height: self.isExpanded ?  UIScreen.main.bounds.height - headerHeight - sheetTopSpace : 50)
                            .background(LinearGradient(gradient: Gradient(colors: [Color("justBlue") , Color("heavenBlue") ]) , startPoint: .topTrailing, endPoint: .bottomLeading))
                            .cornerRadius(isExpanded ? 0 : 10)
                            .clipShape(TopRoundedShape(cornerRadius: isExpanded ? 40 : 10 ))
                            .offset(x: isExpanded ? 0 :  UIScreen.main.bounds.width / 3, y: isExpanded ? 0 : -50)

                        // Button
                        AddButton(isExpanded: self.$isExpanded)
                    }, alignment: .bottom)

The above code does the following:

  1. The modifier is an overlay which contains a ZStack container.
  2. Inside the ZStack there are 2 Views, a HStack and an AddButtonView. The HStack, which can be any container you want, will expand when isExpanded is set to true by modifier its frame and offset
  3. And last, we add the button on top which will be animated to the centre or the right when isExpanded is toggled by changing its x offset value.

Add the following code under ContentView:

struct AddButton: View {
    
    @Binding var isExpanded: Bool
    
    var body: some View {
        Button(action: {
            withAnimation(.spring()) {
                self.isExpanded.toggle()
            }
        }) {
            Image(systemName: "plus")
                .padding()
                .background( isExpanded ? Color.white : Color.clear)
                .foregroundColor( isExpanded ? Color.black : Color.white)
                .cornerRadius(isExpanded ? 25 : 0)
                .rotationEffect(Angle(degrees: isExpanded ? 45 : 90) , anchor: .center)
        }.offset(x: isExpanded ? 0 :  UIScreen.main.bounds.width / 3, y: -50 )
    }
}

This code creates a button that will control the state of the isExpanded field.

Now, try running the app and click that beautiful button… It’s looking awful right?

Add this code above BottomNavBar to fix it up:

  if isExpanded{
                Spacer()
        } 

Now , run the app and everything should work perfectly.

The last thing we need to is to add the addedTimeZones at the top. So, below WatchView, add this:

            if isExpanded{
                AddedTimeZones().transition(.move(edge: .trailing))
            }

Then creates the AddedTimeZones view by adding this above SavedTimeZones struct:

struct AddedTimeZones: View {
    
    var headerHeight: CGFloat = 100
    
    var body: some View {
        ScrollView( .horizontal, showsIndicators: false) {
            HStack(spacing: 10) {
                ForEach(TimeZone.data()){ timeZone in
                    AddedTimeZoneView(timeZone: timeZone)
                }
            }.frame(height: headerHeight).padding()
        }
    }
}

That’s it folks, hope you enjoyed this part. Make sure you share this article everywhere and subscribe for much more amazing stuff to come.

Read the follow-up tutorial here.

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder