Here's a look at the final product. Many of you who have used Instagram will be familiar. The best part, this doesn't have to be strictly applied to sharing social media photos. You can use this type of view to showcase anything. It could be products, news articles, or other photos. Let's get started!
Before getting started, please consider subscribing using this link, and if you aren't reading this on TrailingClosure.com, please come check us out sometime!
Getting Started
If you're following along in Xcode, then you'll need a few photos for this project. I've gathered a few from Unsplash's Nature section to use for this tutorial. You can download them here
Once you have those, fire up Xcode, create a new project, and add the photos to the Assets.xcassets
folder.
Creating the LoadingRectangle
View
The LoadingRectangle
will be used to show the progression of time for each photo. It consists of two Rectangle
Views, one on top of the other. The top Rectangle
increases it's width over time to cover the other.
- Start by creating a new SwiftUI view, called
LoadingRectangle
. - Remove the
Text
that was generated for you, and replace it with aGeometryReader
. This will give you a reference to the frame that we'll use in a second. - Next in order to stack both Rectangles, add a
ZStack
within theGeometryReader
, and place inside twoRectangle
Views. Make sure you give theZStack
a.leading
alignment so our topRectangle
will grow from the left hand side.
Here's what you should have so far:
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
Rectangle()
}
}
}
Next we need modify the top Rectangle
so that it will change size over time. To do this, you need to declare the progress
variable.
var progress: CGFloat
Then modify the second Rectangle
so that its width changes according to the progress
variable.
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
Rectangle()
.frame(width: geometry.size.width * self.progress, height: nil, alignment: .leading)
}
}
}
Finally, you can style the rectangles to your liking. I've gone ahead and modified their corner radius as well as color. Final body
code is below.
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.foregroundColor(Color.white.opacity(0.3))
.cornerRadius(5)
Rectangle()
.frame(width: geometry.size.width * self.progress, height: nil, alignment: .leading)
.foregroundColor(Color.white.opacity(0.9))
.cornerRadius(5)
}
}
}
Combining Images and LoadingRectangle
Let's move over to ContentView.swift
. Xcode generated this file when you made the project.
Start off by declaring an array of image names at the top. These should be the names of the photos we added earlier, and will be the images we display in the "Story".
var imageNames:[String] = ["image01","image02","image03","image04","image05","image06","image07"]
Similar to what we did in LoadingRectangle
, replace the Text
with a ZStack
wrapped in a GeometryReader
and the place an Image
and horizontal stack of LoadingRectangle
s inside.
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
Image(self.imageNames[0])
.resizable()
.edgesIgnoringSafeArea(.all)
.scaledToFill()
.frame(width: geometry.size.width, height: nil, alignment: .center)
.animation(.none)
HStack(alignment: .center, spacing: 4) {
ForEach(self.imageNames.indices) { x in
LoadingRectangle(progress: 1.0)
.frame(width: nil, height: 2, alignment: .leading)
.animation(.linear)
}
}.padding()
}
}
}
I've gone ahead and added a few things for time's sake, but feel free to go back in and experiment with some of the settings to get a feel for what I've done (specifically with the modifiers for the image and the frame for the LoadingRectangle
)
For right now we've added two placeholders in the code above.
- For the current image being displayed.
Image(self.imageNames[0])
- For the progress on each
LoadingRectangle
.LoadingRectangle(progress: 1.0)
In order to move forward we need to create our StoryTimer
which controls the movement from photo to photo as well as feeds its progress to the LoadingRectangle
.
Creating the StoryTimer
The last piece we need to create is a timer. It's going to be an ObservableObject which publishes its progress
variable to the rest of the our pieces.
class StoryTimer: ObservableObject {
@Published var progress: Double
private var interval: TimeInterval
private var max: Int
private let publisher: Timer.TimerPublisher
private var cancellable: Cancellable?
init(items: Int, interval: TimeInterval) {
self.max = items
self.progress = 0
self.interval = interval
self.publisher = Timer.publish(every: 0.1, on: .main, in: .default)
}
func start() {
self.cancellable = self.publisher.autoconnect().sink(receiveValue: { _ in
var newProgress = self.progress + (0.1 / self.interval)
if Int(newProgress) >= self.max { newProgress = 0 }
self.progress = newProgress
})
}
}
Take a look at the initializer for the StoryTimer
. It takes in the number of items in our "Story" and a TimeInterval
for how long we should display each item.
When our ContentView
appears, we will make a call to the start()
function to start receiving values from the TimerPublisher
. Each time a new value is received, we update the progress
variable that is published by our StoryTimer
class. This in turn triggers our ContentView
to update our LoadingRectangle
with the correct progress and displays the correct image on the screen.
Removing the Placeholders in ContentView
First create an instance of the StoryTimer
at the top of ContentView
.
@ObservedObject var storyTimer: StoryTimer = StoryTimer(items: 7, interval: 3.0)
Then replace the line where you declared the Image
with the following:
Image(self.imageNames[Int(self.storyTimer.progress)])
What this does is grab the progress from the StoryTimer
and select the corresponding image via its index.
The second placeholder we need to update is the one for progress on our LoadingRectangle
. Replace its instantiation with the following:
LoadingRectangle(progress: min( max( (CGFloat(self.storyTimer.progress) - CGFloat(x)), 0.0) , 1.0) )
This may look like a lot is going on here, but really it's just some simple math. self.storyTimer.progress
values range from 0
to N
(Number of photos to display). Wehereas the Loadingrectangle
needs a progress value from 0.0
to 1.0
. We are doing the conversion based on the index of each LoadingRectangle
(x
in this case`)
Starting the Timer
Finally, at the bottom of the ZStack
in ContentView.swift
you need to start the StoryTimer
when the view appears.
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
/* Image and LoadingRectangles Here */
}
.onAppear { self.storyTimer.start() }
.onDisappear {self.storyTimer.cancel() }
}
}
If all went well you should be able to hit the run button and get what you see below.
Extra Credit
If you'd like to go the extra mile, you can implement TapGesture
s for each half of the screen to cycle through photos like in the actual Instagram app.
Add The TapGesture
in ContentView.swift
Add the following HStack
at the bottom of the ZStack
inside the body of your main ContentView
.
HStack(alignment: .center, spacing: 0) {
Rectangle()
.foregroundColor(.clear)
.contentShape(Rectangle())
.onTapGesture {
self.storyTimer.advance(by: -1)
}
Rectangle()
.foregroundColor(.clear)
.contentShape(Rectangle())
.onTapGesture {
self.storyTimer.advance(by: 1)
}
}
Modify StoryTimer
Next we need to add a function to StoryTimer
class that allows us to advance or subtract its progress. We want to go ahead to the next photo but not skip too far ahead and cut into its showing time. The function below calculates the current item index and then advances the progress (or subtracts if you supply it a negative number).
func advance(by number: Int) {
let newProgress = max((Int(self.progress) + number) % self.max , 0)
self.progress = Double(newProgress)
}
All Done
Give it a run again! Try tapping from the left and right sides of the screen to see the photos move forward and backwards. The best part is, the LoadingRectangle
still animates seamlessly.
Support Future Posts
If you enjoyed this post, please consider subscribing to my website using this link, and if you aren't reading this on TrailingClosure.com, please come check us out sometime!