Composing the interface

The interface of our app consists of two main sections. The upper section should display the currently edited photo. The bottom section should show preview thumbnails of the photo, which the user can tap on to apply a filter to his photo.

We start by inserting a VStack containing an Image view with our “testImage” file into our ContentView.

struct ContentView: View {
    var body: some View {
        VStack {
            Image("testImage")
        }
    }
}

This Image view is supposed to be three-quarters of the screen height. To know the screen height of the device the app runs on, we embed the VStack in a GeometryReader. Now, we can use it to frame our Image properly.

GeometryReader { geometry in
    VStack {
        Image("testImage")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: geometry.size.width, height: geometry.size.height*0.75)
            .clipped()
    }
}

Below this Image view, we want to place multiple Image views for the filter thumbnails. We want to wrap them into a horizontal ScrollView that is a quarter as high as the screen’s height. To enable horizontal scrolling, we have to insert an HStack into the ScrollView.

VStack {
    Image("testImage")
        //...
    ScrollView(.horizontal, showsIndicators: false) {
        HStack {
            
        }
    }
        .frame(width: geometry.size.width, height: geometry.size.height*(1/4))
}

Within the HStack there should be one thumbnail containing each filter’s preview image and the corresponding filter name. 

HStack {
    VStack {
        Text("Original")
            .foregroundColor(Color("LightGray"))
        Image("testImage")
            .renderingMode(.original)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .cornerRadius(20)
            .clipped()
    }
        .padding(.leading, 10)
        .padding(.trailing, 10)
}

Each preview Image view should be 15 percent as high and 20 percent as wide as the device’s screen.

Image("testImage")
    //...
    .aspectRatio(contentMode: .fill)
    .frame(width: geometry.size.width*(21/100), height: geometry.size.height*(15/100))
    //...

Let’s outsource the thumbnail Image view by CMD-clicking on the VStack and selecting “Extract Subview”. We call this new subview ThumbnailView.

In the ThumbnailView struct, we now add properties for the preview Image’s width and height and the filter’s name and replace the static values with them.

struct ThumbnailView: View {
    
    let width: CGFloat
    let height: CGFloat
    let filterName: String
    
    var body: some View {
        VStack {
            Text(filterName)
                .foregroundColor(Color("LightGray"))
            Image("testImage")
                .renderingMode(.original)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: width, height: height)
                .cornerRadius(20)
                .clipped()
        }
            .padding(.leading, 10)
            .padding(.trailing, 10)
    }
}

Then, we initialize these values from our ContentView.

HStack {
    ThumbnailView(width: geometry.size.width*(21/100), height: geometry.size.height*(15/100), filterName: "Original")
}

Since we didn’t implement any photo processing functionality yet, we only use this “original” thumbnail for now. We will add more ThumbnailViews later.

Finally, we embed the entire GeometryReader of our ContentView in a NavigationView. Then we add a navigation bar with the title “Filter App”.

NavigationView {
    GeometryReader { geometry in
        //...
    }
        .navigationBarTitle("Filter App", displayMode: .inline)
}

Next, we add a navigation bar item for the left side of the navigation bar. We want to show a Button where the user can tap on to open his gallery. To do this, apply the .toolbar modifier below the .navigationBarTitle. Inside the toolbar, we can place various ToolbarItems. When creating a ToolbarItem, we can choose the position of the item by using the “placement” argument.

.toolbar(content: {
    ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
            print("Open image gallery.")
        }) {
            Image(systemName: "photo")
                .imageScale(.large)
        }
    }
})

Let’s outsource the Button view as well:

.toolbar(content: {
    ToolbarItem(placement: .navigationBarLeading) {
        GalleryButton()
    }
})

At the right side of the navigation bar, we add another ToolbarItem to save the edited photo. We also outsource this view.

.toolbar(content: {
    ToolbarItem(placement: .navigationBarLeading) {
        GalleryButton()
    }
    ToolbarItem(placement: .navigationBarTrailing) {
        SaveButton()
    }
})

The outsourced SaveButton:

struct SaveButton: View {
    var body: some View {
        Button(action: {
            print("Save edited photo.")
        }) {
            Image(systemName: "square.and.arrow.down")
                .imageScale(.large)
        }
    }
}

All right, that’s it! We’ve set up the entire interface for our photo filter app. This is how your preview should look like now:

3 replies on “Composing the interface”

I upgraded to XCode 12.0 and I tried to build for an iPhone 11 which uses IOS 14. But I get the error:

‘ToolbarItem’ is only available in IOS 14.0 or newer.

Here is my code for ContentView.swift:

//
// ContentView.swift
// Photo Editor App
//
// Created by Robert Hélie on 2020-08-14.
// Copyright © 2020 Robert Hélie. All rights reserved.
//

import SwiftUI

struct ContentView: View {

@ObservedObject var imageController : ImageController

@State var showImagePicker = false
@State var showCamera = false
@State var showAlert = false

var body: some View {
NavigationView {
GeometryReader { geometry in
VStack {
Image(uiImage: self.imageController.displayedImage!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width:geometry.size.width, height: geometry.size.height*(3/4))
.clipped()

ScrollView(.horizontal, showsIndicators: false) {
HStack {
ThumbnailView(imageController: self.imageController, width: geometry.size.width*(21/100), height: geometry.size.height*(15/100), filterName: “Original”, filter: .Original)
ThumbnailView(imageController: self.imageController, width: geometry.size.width*(21/100), height: geometry.size.height*(15/100), filterName: “Sepia”, filter: .Sepia)
}
}
.frame(width: geometry.size.width, height: geometry.size.height*(1/4))
}
}
.navigationBarTitle(“Filter App”, displayMode: .inline)
.toolbar(content: {
ToolbarItem(placement: .navigationBarLeading) {
GalleryButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
SaveButton()
}
})

}
.alert(isPresented: $showAlert) {
Alert (title: Text(“Erreur”), message: Text(“L’appareil n’a pas de caméra.”), dismissButton: .default(Text(“Ok”)))
}
}

init() {
imageController = ImageController()
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

struct SaveButton : View {
var body: some View {
Button(action:{
print(“Save photo”)
}) {
Image(systemName: “square.and.arrow.down”)
.foregroundColor(Color(UIColor.black))
.imageScale(.large)
}
}
}

struct ThumbnailView: View {

@ObservedObject var imageController: ImageController

var width: CGFloat
var height: CGFloat
var filterName: String
var filter: FilterType

var body: some View {
Button(action: {
self.imageController.displayedImage = self.imageController.generateFilteredImage(inputImage: self.imageController.originalImage!, filter: self.filter)
}) {
VStack {
Text(filterName)
.foregroundColor(Color(“LightGray”))
Image(uiImage: imageController.generateFilteredImage(inputImage:
imageController.thumbnailImage!, filter: self.filter))
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.cornerRadius(20)
.clipped()
}
}
.padding(.leading, 10)
.padding(.trailing, 10)
}
}

struct GalleryButton: View {
var body: some View {
Button(action: {
print(“Open image gallery.”)
}) {
Image(systemName:”photo”)
.imageScale(.large)
}
}
}

Leave a Reply

Your email address will not be published. Required fields are marked *