Creating Custom Views with SwiftUI

A key step in learning to develop apps using SwiftUI is learning how to declare user interface layouts both by making use of the built-in SwiftUI views as well as building your own custom views. This chapter will introduce the basic concepts of SwiftUI views and outline the syntax used to declare user interface layouts and modify view appearance and behavior.

1.1 SwiftUI Views

User interface layouts are composed in SwiftUI by using, creating and combining views. An important first step is to understand what is meant by the term “view”. Views in SwiftUI are declared as structures that conform to the View protocol. In order to conform with the View protocol, a structure is required to contain a body property and it is within this body property that the view is declared.

SwiftUI includes a wide range of built-in views that can be used when constructing a user interface including text label, button, text field, menu, toggle and layout manager views. Each of these is a self-contained instance that complies with the View protocol. When building an app with SwiftUI you will use these views to create custom views of your own which, when combined, constitute the appearance and behavior of your user interface.

These custom views will range from subviews that encapsulate a re-useable subset of view components (perhaps a secure text field and a button for logging in to screens within your app) to views that encapsulate the user interface for an entire screen. Regardless of the size and complexity of a custom view or the number of child views encapsulated within, a view is still just an instance that defines some user interface appearance and behavior.

1.2 Creating a Basic View

In Xcode, custom views are contained within SwiftUI View files. When a new SwiftUI project is created, Xcode will create a single SwiftUI View file containing a single custom view consisting of the single Text view component. Additional view files can be added to the project by selecting the File -> New -> File… menu option and choosing the SwiftUI View file entry from the template screen.

The default SwiftUI View file is named ContentView.swift and reads as follows:

import SwiftUI

struct ContentView: View {

  var body: some View {
        Text("Hello World")
    }
}

struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        ContentView()
    }
}

The view is named ContentView and is declared as conforming to the View protocol. It also includes the mandatory body property which, in turn contains an instance of the built-in Text view component which is initialized with a string which reads “Hello World”.

The second structure in the file is needed to create an instance of ContentView so that it appears in the preview canvas, a topic which will be covered in detail in later chapters.

1.3 Adding Additional Views

Additional views can be added to a parent view by placing them in the body. The body property, however, is configured to return a single view. Adding an additional view, as is the case in the following example, will generate a syntax error:

struct ContentView: View {

    var body: some View {
        Text("Hello World")
        Text("Goodbye World") // Invalid structure
    }
}

To add additional views, those views must be placed in a container view such as a stack or form. The above example could, therefore, be modified to place the two Text views in a vertical stack (VStack) view which, as the name suggests, positions views vertically within the containing view:

struct ContentView: View {

    var body: some View {
      
        VStack {
            Text("Hello World")
            Text("Goodbye World")
        }
    }
}

SwiftUI views are hierarchical by nature, starting with parent and child views. This allows views to be nested to multiple levels to create user interfaces of any level of complexity. Consider, for example, the following view hierarchy diagram:

Figure 19‑1

The equivalent view declaration for the above view would read as follows:

struct ContentView: View {

    var body: some View {
        VStack {
            VStack {
                Text("Text 1")
                Text("Text 2")
                HStack {
                    Text("Text 3")
                    Text("Text 4")
                }
            }
            Text("Text 5")
        }
    }
}

A notable exception to the requirement that multiple views be embedded in a container is that multiple Text views count as a single view when concatenated. The following, therefore, is a valid view declaration:

struct ContentView: View {

    var body: some View {
        Text("Hello, ") + Text("how ") + Text("are you?")
    }
}

Note that in the above examples the closure for the body property does not have a return statement. This is because the closure essentially contains a single expression (implicit returns from single expressions were covered in the chapter entitled An Overview of Swift 5 Functions, Methods and Closures). As soon as extra expressions are added to the closure, however, it will be necessary to add a return statement, for example:

struct ContentView: View {

    var body: some View {

        var myString: String = "Welcome to SwiftUI"
      
        return VStack {
            Text("Hello World")
            Text("Goodbye World")
        }
    }
}

1.4 Working with Subviews

Apple recommends that views be kept as small and lightweight as possible. This promotes the creation of reusable components, makes view declarations easier to maintain and results in more efficient layout rendering.

If you find that a custom view declaration has become large and complex, identify areas of the view that can be extracted into a subview. As a very simplistic example, the HStack view in the above example could be extracted as a subview named “MyHStackView” as follows:

struct ContentView: View {

    var body: some View {
        VStack {
            VStack {
                Text("Text 1")
                Text("Text 2")
                MyHStackView()
            }
            Text("Text 5")
        }
    }
}

struct MyHStackView: View {
    var body: some View {
        HStack {
            Text("Text 3")
            Text("Text 4")
        }
    }
}

1.5 Views as Properties

In addition to creating subviews, views may also be assigned to properties as a way to organize complex view hierarchies. Consider the following example view declaration:

struct ContentView: View {

    var body: some View {
        VStack {
            Text("Main Title")
                .font(.largeTitle)
            HStack {
                Text("Car Image")
                Image(systemName: "car.fill")
            }
        }
    }
}

Any part of the above declaration can be moved to a property value, and then referenced by name. In the following declaration, the HStack has been assigned to a property named carStack which is then referenced within the VStack layout:

struct ContentView: View {
  
    let carStack = HStack {
        Text("Car Image")
        Image(systemName: "car.fill")
    }

    var body: some View {
        VStack {
            Text("Main Title")
                .font(.largeTitle)
            carStack
        }
    }
}

1.6 Modifying Views

It is unlikely that any of the views provided with SwiftUI will appear and behave exactly as required without some form of customization. These changes are made by applying modifiers to the views.

All SwiftUI views have sets of modifiers which can be applied to make appearance and behavior changes. These modifiers take the form of methods that are called on the instance of the view and essentially wrap the original view inside another view which applies the necessary changes. This means that modifiers can be chained together to apply multiple modifications to the same view. The following, for example, changes the font and foreground color of a Text view:

Text("Text 1")
    .font(.headline)
    .foregroundColor(.red)

Similarly, the following example uses modifiers to configure an Image view to be resizable with the aspect ratio set to fit proportionally within the available space:

Image(systemName: "car.fill")
    .resizable()
    .aspectRatio(contentMode: .fit)

Modifiers may also be applied to custom subviews. In the following example, the font for both Text views in the previously declared MyHStackView custom view will be changed to use the large title font style:

MyHStackView()
    .font(.largeTitle)

1.7 Working with Text Styles

In the above example the font used to display text on a view was declared using a built-in text style (in this case the large title style).

iOS provides a way for the user to select a preferred text size which applications are expected to adopt when displaying text. The current text size can be configured on a device via the Settings -> Display & Brightness -> Text Size screen which provides a slider to adjust the font size as shown in Figure 19‑2:

Figure 19‑2

If a font has been declared on a view using a text style, the text size will dynamically adapt to the user’s preferred font size. Almost without exception, the built-in iOS apps adopt the preferred size setting selected by the user when displaying text and Apple recommends that third-party apps also conform to the user’s chosen text size. The following text style options are currently available:

  • headline
  • subheadline
  • body
  • callout
  • footnote
  • caption

If none of the text styles meet your requirements, it is also possible to apply custom fonts by declaring the font family and size, though this font setting will be fixed in size regardless of the user’s preferred text size selection:

Text("Sample Text")
    .font(.custom("Copperplate", size: 70)) 

The above custom font selection will render the Text view as follows:

Figure 19‑3

1.8 Modifier Ordering

When chaining modifiers, it is important to be aware that the order in which they are applied can be significant. Both border and padding modifiers have been applied to the following Text view.

Text("Sample Text")
    .border(Color.black)
    .padding()

The border modifier draws a black border around the view and the padding modifier adds space around the view. When the above view is rendered it will appear as shown in Figure 19‑4:

Figure 19‑4

Given that padding has been applied to the text, it might be reasonable to expect there to be a gap between the text and the border. In fact, the border was only applied to the original Text view. Padding was then applied to the modified view returned by the border modifier. The padding is still applied to the view, but outside of the border. For the border to encompass the padding, the order of the modifiers needs to be changed so that the border is drawn on the view returned by the padding modifier:

Text("Sample Text")
    .padding()
    .border(Color.black)

With the modifier order switched, the view will now be rendered as follows:

Figure 19‑5

If you don’t see the expected effects when working with chained modifiers, keep in mind this may be because of the order in which they are being applied to the view.

1.9 Custom Modifiers

SwiftUI also allows you to create your own custom modifiers. This can be particularly useful if you have a standard set of modifiers that are frequently applied to views. Suppose that the following modifiers are a common requirement within your view declarations:

Text("Text 1")
    .font(.largeTitle)
    .background(Color.white)
    .border(Color.gray, width: 0.2)
    .shadow(color: Color.black, radius: 5, x: 0, y: 5)

Instead of applying these four modifiers each time text with this appearance is required, a better solution is to group them into a custom modifier and then reference that modifier each time the modification is needed. Custom modifiers are declared as structs that conform to the ViewModifier protocol and, in this instance, might be implemented as follows:

struct StandardTitle: ViewModifier {

   func body(content: Content) -> some View {
        content
            .font(.largeTitle)
            .background(Color.white)
            .border(Color.gray, width: 0.2)
            .shadow(color: Color.black, radius: 5, x: 0, y: 5)
    }
}

The custom modifier is then applied when needed by passing it through to the modifier() method:

Text("Text 1")
    .modifier(StandardTitle())
Text("Text 2")
    .modifier(StandardTitle())

With the custom modifier implemented, changes can be made to the StandardTitle implementation and those changes will automatically propagate through to all views that use the modifier. This avoids the need to manually change the modifiers on multiple views.

1.10 Basic Event Handling

Although SwiftUI is described as being data driven, it is still necessary to the handle events that are generated when a user interacts with the views in the user interface. Some views, such as the Button view, are provided solely for the purpose of soliciting user interaction. In fact, the Button view can be used to turn a variety of different views into a “clickable” button. A Button view needs to be declared with the action method to be called when a click is detected together with the view to act as the button content. It is possible, for example, to designate an entire stack of views as a single button. In most cases, however, a Text view will typically be used as the Button content. In the following implementation, a Button view is used to wrap a Text view which, when clicked will call a method named buttonPressed():

struct ContentView: View {

    var body: some View {
        Button(action: buttonPressed) {
            Text("Click Me")
        }
    }

    func buttonPressed() {
        // Code to perform action here
    } 
}

Instead of specifying an action function, the code to be executed when the button is clicked may also be specified as a closure in-line with the declaration:

Button(action: {
    // Code to perform action here
}) {
    Text("Click Me")
}

Another common requirement is to turn an Image view into a button, for example:

Button(action: {
    print("hello")
}) {
    Image(systemName: "square.and.arrow.down")
}

1.11 The onAppear and onDisappear Methods

Action methods may be declared on specific views to perform initialization and deinitialization tasks when the view appears and disappears within a layout. This is achieved by using the onAppear and onDisappear instance methods, for example:

Text("Hello World")
    .onAppear(perform: {
        // Code here to perform when the view appears
    })
    .onDisappear(perform: {
        // Code here to perform when view disappears
    })

1.12 Building Custom Container Views

As outlined earlier in this chapter, subviews provide a useful way to divide a view declaration into small, lightweight and reusable blocks. One limitation of subviews, however, is that the content of the container view is static. In other words, it is not possible to dynamically specify the views that are to be included at the point that a subview is included in a layout. The only children included in the subview are those that are specified in the original declaration.

Consider the following subview which consists of three TextViews contained within a VStack and modified with custom spacing and font settings.

struct MyVStack: View {

    var body: some View {
        VStack(spacing: 10) {
            Text("Text Item 1")
            Text("Text Item 2")
            Text("Text Item 3")
        }
        .font(.largeTitle)
    }
}

To include an instance of MyVStack in a declaration, it would be referenced as follows:

MyVStack()

Suppose, however, that a VStack with a spacing of 10 and a large font modifier is something that is needed frequently within a project, but in each case, different child views are required to be contained within the stack. While this flexibility isn’t possible using subviews, it can be achieved using the SwiftUI ViewBuilder closure attribute when constructing custom container views.

A ViewBuilder takes the form of a Swift closure which can be used to create a custom view comprised of multiple child views, the content of which does not need to be declared until the view is used within a layout declaration. The ViewBuilder closure takes the content views and returns them as a single view which is, in effect, a dynamically built subview.

The following is an example of using the ViewBuilder attribute to implement our custom MyVStack view:

struct MyVStack<Content: View>: View {

  let content: () -> Content

  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  var body: some View {
    VStack(spacing: 10) {
      content()
   }
   .font(.largeTitle)
  }
}

Note that this declaration still returns an instance that complies with the View protocol and that the body contains the VStack declaration from the previous subview. Instead of including static views to be included in the stack, however, the child views of the stack will be passed to the initializer, handled by ViewBuilder and embedded into the VStack as child views. The custom MyVStack view can now be initialized with different child views wherever it is used in a layout, for example:

MyVStack {
    Text("Text 1")
    Text("Text 2")
    HStack {
        Image(systemName: "star.fill")
        Image(systemName: "star.fill")
        Image(systemName: "star")
    }
}

1.13 Summary

SwiftUI user interfaces are declared in SwiftUI View files and are composed of components that conform to the View protocol. To conform with the View protocol a structure must contain a property named body which is itself a View.

SwiftUI provides a library of built-in components that can be used to design user interface layouts. The appearance and behavior of a view can be configured by applying modifiers and views can be modified and grouped together to create custom views and subviews. Similarly, custom container views can be created using the ViewBuilder closure property.

When a modifier is applied to a view, a new modified view is returned and subsequent modifiers are then applied to this modified view. This can have significant implications for the order in which modifiers are applied to a view.