SwiftUI - Instagram Story Tutorial

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

Each Individual Piece is a Loading Rectangle

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.

  1. Start by creating a new SwiftUI view, called LoadingRectangle.
  2. Remove the Text that was generated for you, and replace it with a GeometryReader. This will give you a reference to the frame that we'll use in a second.
  3. Next in order to stack both Rectangles, add a ZStack within the GeometryReader, and place inside two Rectangle Views. Make sure you give the ZStack a .leading alignment so our top Rectangle 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 LoadingRectangles 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.

  1. For the current image being displayed. Image(self.imageNames[0])
  2. 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.

Finished Product

Extra Credit

If you'd like to go the extra mile, you can implement TapGestures 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!