Itbeginstagram, Learning SwiftUI Layout
June 4, 2022 — Simon Jespersen
I have previously gone through some SwiftUI tutorials by Design+Code. That was quite some time ago, so as a refresher on how layout works in SwiftUI I wanted to create a basic clone of some views in the Instagram app. I broke the project into three tasks; creating the stories, the main timeline, and a view for searching.
Stories
The stories are a collection of circle images with an outline. There is no built-in view for this, but creating it was a breeze with the tools SwiftUI provides.
struct CircleAvatar: View {
var isFinished: Bool = false
var showAccountName: Bool = true
var accountName: String
var size: CGFloat? = 50
@EnvironmentObject var storyState: StoryState
var body: some View {
VStack {
Image(accountName)
.resizable()
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(Circle().stroke(.background, lineWidth: 2))
.padding(2)
.overlay(Circle().stroke(isFinished ? .black : .red, lineWidth: 3))
if showAccountName {
Text(accountName)
.font(.caption)
}
}
.padding(.vertical)
.onTapGesture {
storyState.showStory = true
}
}
}
Each CircleAvatar takes in some parameters, but the important part here is the accountName. Each account name is associated with an image asset that I have added to the app. The account name is given to the built-in Image view and we then use modifiers to make the image resizable, set width and height, clip it to a circle, and style the outline based on if the story is finished or not. There is also some state management for opening the story in an overlay, but this will be covered later.
View is a type that represents part of your app’s user interface and provides modifiers that you use to configure views.
Post
A post is a combination of an image, some text, and a couple of actions. The real Instagram application has some additional things like comments and a description for a post, but I decided to leave it as I figured it wouldn't add anything to my learning.
struct Post: View {
var data: PostData
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
CircleAvatar(showAccountName: false, accountName: data.user.accountName, size: 30)
Text(data.user.accountName)
.font(.subheadline)
.bold()
Spacer()
Image(systemName: "ellipsis")
.imageScale(.large)
}
.padding(.horizontal, 8)
AsyncImage(url: URL(string: data.url))
.frame(height: 300)
HStack(spacing: 8) {
Image(systemName: "heart")
.imageScale(.large)
Image(systemName: "bubble.right")
.imageScale(.large)
Image(systemName: "paperplane")
.imageScale(.large)
Spacer()
Image(systemName: "bookmark")
.imageScale(.large)
}
.padding(8)
}
}
}
The view is split into three rows using VStack, and each row's content is placed by HStack. Here we reuse our custom CircleAvatar without showing the account name, this means we get the same on-click functionality as we got in the Story view. For the actions, we load icons from the SF Symbols library, and the main image of each post is loaded from an URL.
Apple provides an app for searching SF Symbols
Search
The search view is a combination of a search bar that will hide when the user scrolls and a grid view of images. What is special about this view is that we used LazyVGrid which will only create images when needed. This is to optimize the performance if there are lots of images loaded into the view.
private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
private var colors: [Color] = [.yellow, .purple, .green]
private var gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)
struct SearchView: View {
@State private var searchText = ""
var body: some View {
HideableTopView {
HStack {
SearchBar(text: $searchText)
}
} content: {
LazyVGrid(columns: gridItemLayout, spacing: 0) {
ForEach(0 ... 100, id: \.self) {
Image(systemName: symbols[$0 % symbols.count])
.font(.system(size: 30))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)
.background(colors[$0 % colors.count])
.padding(1)
}
}
}
}
}
I borrowed some code to make the search bar disappear and reappear as it does on Instagram. In iOS 15 there is a built-in version of this for the NavigationView, but it is a bit different from the version used on Instagram.
Putting it Together
What we created has been put into a TabView to combine the different pages, it was pretty straightforward.
struct LandingView: View {
@StateObject var storyState = StoryState()
var body: some View {
TabView {
HomeView()
.tabItem {
Label("", systemImage: "house")
}
.environmentObject(storyState)
SearchView()
.tabItem {
Label("", systemImage: "magnifyingglass")
}
ReelsView()
.tabItem {
Label("", systemImage: "play.square")
}
ShopView()
.tabItem {
Label("", systemImage: "bag")
}
ProfileView()
.tabItem {
Label("", systemImage: "person.crop.circle")
}
}
.overlay(StoryOverlay().environmentObject(storyState))
}
}
State Management
As this was mainly a refresher on layouts in SwiftUI, there is not a lot of data and state. I ended up using an ObservableObject to let the app know if the Story overlay should be open or not. It made the code look a bit messy so I think this is a good point to start looking into state management in a SwiftUI application.
You can view the Itbeginstagram source code on Github.