View Extensions for Better Code Readability

When we are writing our view code, we want to make it look as clean as possible. Good indentation, avoiding long lines, clear and meaningful names, etc. It all contributes to code readability. Nevertheless, sometimes it gets out of control and our views become hard to read and ugly to look at. You probably already know you can use ViewModifier and custom Views to encapsulate some of that code. And although that helps, today we will talk about a slightly different way of reusability, that can lead to even more readable code: extensions to the View protocol. They are extremely simple, so let’s get started.

A Basic Example

With Xcode 11 Beta 5, we saw the disappearance and deprecation of some helpful modifiers, but modifiers, are nothing more than methods that extend the View protocol. So why not bring back some of them. As an example, let’s see the no longer available modifier:

Text("Hello World").border(Color.black, width: 3, cornerRadius: 5)

Beta 5 release notes indicate that we should now use an overlay() or a background():

Text("Hello World").overlay(MyRoundedBorder(cornerRadius: 5).strokeBorder(Color.black, lineWidth: 3))

Not cool. Not only it is longer code, but also, it is too hard to understand what it’s doing with a simple glance. At first we might think of creating a ViewModifier and applying it with .modifier():

Text("Hello World").modifier(RoundedBorder(Color.black, width: 3, cornerRadius: 5))
struct MyRoundedBorder<S>: ViewModifier where S : ShapeStyle {
    let shapeStyle: S
    var width: CGFloat = 1
    let cornerRadius: CGFloat
    
    init(_ shapeStyle: S, width: CGFloat, cornerRadius: CGFloat) {
        self.shapeStyle = shapeStyle
        self.width = width
        self.cornerRadius = cornerRadius
    }
    
    func body(content: Content) -> some View {
        return content.overlay(RoundedRectangle(cornerRadius: cornerRadius).strokeBorder(shapeStyle, lineWidth: width))
    }
}

It is better, but we can improve code readability even more. Let’s create a View extension that does exactly the same as the original .border() modifier. We’ll call it .addBorder() to avoid any possible future conflict with the framework’s own modifiers:

Text("Hello World").addBorder(Color.blue, width: 3, cornerRadius: 5)
extension View {
    public func addBorder<S>(_ content: S, width: CGFloat = 1, cornerRadius: CGFloat) -> some View where S : ShapeStyle {
        return overlay(RoundedRectangle(cornerRadius: cornerRadius).strokeBorder(content, lineWidth: width))
    }
}

Applying a ViewModifier Conditionally

You sure faced a moment, when you wanted to be able to apply a modifier, but only if a certain condition was met. Unfortunately, none of the following would work:

Text("Hello world").modifier(flag ? ModifierOne() : EmptyModifier())
Text("Hello world").modifier(flag ? ModifierOne() : ModifierTwo())

The compiler will complain, and rightfully so. The reason is both expressions in the ternary operator, must have the same concrete type… but they have not.

Here, writing a view extension will be very useful. We are going to create an extension called .conditionalModifier(), and it will apply the specify modifier(s), only if the condition is met:

extension View {
    // If condition is met, apply modifier, otherwise, leave the view untouched
    public func conditionalModifier<T>(_ condition: Bool, _ modifier: T) -> some View where T: ViewModifier {
        Group {
            if condition {
                self.modifier(modifier)
            } else {
                self
            }
        }
    }

    // Apply trueModifier if condition is met, or falseModifier if not.
    public func conditionalModifier<M1, M2>(_ condition: Bool, _ trueModifier: M1, _ falseModifier: M2) -> some View where M1: ViewModifier, M2: ViewModifier {
        Group {
            if condition {
                self.modifier(trueModifier)
            } else {
                self.modifier(falseModifier)
            }
        }
    }
}

And here’s an example using our new extensions:

struct ContentView: View {
    @State private var tapped = false
    
    var body: some View {
        VStack {
            Spacer()
                        
            // This line alternates between two modifiers
            Text("Hello World").conditionalModifier(tapped, PlainModifier(), CrazyModifier())
            Spacer()
            
            // This line alternates between a modifier, or none at all
            Text("Hello World").conditionalModifier(tapped, PlainModifier())
            
            Spacer()
            Button("Tap Me!") { self.tapped.toggle() }.padding(.bottom, 40)
        }
    }
}

struct PlainModifier: ViewModifier {
    func body(content: Content) -> some View {
        return content
            .font(.largeTitle)
            .foregroundColor(.blue)
            .autocapitalization(.allCharacters)
    }
}

struct CrazyModifier: ViewModifier {
    var font: Font = .largeTitle
    
    func body(content: Content) -> some View {
        return content
            .font(.largeTitle)
            .foregroundColor(.red)
            .autocapitalization(.words)
            .font(Font.custom("Papyrus", size: 24))
            .padding()
            .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.purple, lineWidth: 3.0))
    }
}

Create Convenience Initializers

Not only you can extend a view to add new modifiers, you can also create convenience initializers. A good example is the Image view. We can create a new initializer that checks for the image specified, and if it doesn’t exist, it creates a default image in its place. We will create two initializers. Once uses a system default image, and the other let you specify a name for an alternative image.

Image("landscape", defaultSystemImage: "questionmark.circle.fill")
Image("landscape", defaultImage: "empty-photo")
extension Image {
    init(_ name: String, defaultImage: String) {
        if let img = UIImage(named: name) {
            self.init(uiImage: img)
        } else {
            self.init(defaultImage)
        }
    }
    
    init(_ name: String, defaultSystemImage: String) {
        if let img = UIImage(named: name) {
            self.init(uiImage: img)
        } else {
            self.init(systemName: defaultSystemImage)
        }
    }
}

Simplifying the Use of View Preferences

If you read my article on view preferences, you know how powerful they are. These little creatures let you pass information from child views to their ancestors. And they are specially useful in passing geometry information. Unfortunately, when you start using them, you’ll see your code starts to clutter. Well, guess what, we are going to use view extensions to clean our code. We will be using preferences without even knowing it.

Our goal is to create two extensions, one to set the bounds of a view in a preference and assigning it an integer id:

.saveBounds(viewId: 3)

and one to retrieve the bounds of another view identified with an integer and place them on a Binding:

.retrieveBounds(viewId: 3, $bounds)

As you can see, your code will not have to deal with .preference(), .onPreferenceChange(), .transformPreference(), etc. Here’s an example on how you can use the extensions:

IMPORTANT NOTE ABOUT VIEW PREFERENCES
When using view preferences, you may use the geometry information from a child, in order to layout one of its ancestors. If that is the case, you should exercise caution. You risk getting stuck in a recursive loop, if the ancestor reacts to changes in the layout of a child, and the child reacts to the changes of the ancestor. Learn more under the title Use Preferences Wisely in Inspecting the View Tree – Part 1.

struct ContentView: View {
    @State private var littleRect: CGRect = .zero
    @State private var bigRect: CGRect = .zero
    
    var body: some View {
        VStack {
            Text("Little = \(littleRect.debugDescription)")
            Text("Big = \(bigRect.debugDescription)")
            HStack {
                LittleView()
                BigView()
            }
            .coordinateSpace(name: "mySpace")
            .retrieveBounds(viewId: 1, $littleRect)
            .retrieveBounds(viewId: 2, $bigRect)
        }
    }
}

struct LittleView: View {
    var body: some View {
        Text("Little Text").font(.caption).padding(20).saveBounds(viewId: 1, coordinateSpace: .named("mySpace"))
    }
}

struct BigView: View {
    var body: some View {
        Text("Big Text").font(.largeTitle).padding(20).saveBounds(viewId: 2, coordinateSpace: .named("mySpace"))
    }
}

And here’s where all the magic happens:

extension View {
    public func saveBounds(viewId: Int, coordinateSpace: CoordinateSpace = .global) -> some View {
        background(GeometryReader { proxy in
            Color.clear.preference(key: SaveBoundsPrefKey.self, value: [SaveBoundsPrefData(viewId: viewId, bounds: proxy.frame(in: coordinateSpace))])
        })
    }
    
    public func retrieveBounds(viewId: Int, _ rect: Binding<CGRect>) -> some View {
        onPreferenceChange(SaveBoundsPrefKey.self) { preferences in
            DispatchQueue.main.async {
                // The async is used to prevent a possible blocking loop,
                // due to the child and the ancestor modifying each other.
                let p = preferences.first(where: { $0.viewId == viewId })
                rect.wrappedValue = p?.bounds ?? .zero
            }
        }
    }
}

struct SaveBoundsPrefData: Equatable {
    let viewId: Int
    let bounds: CGRect
}

struct SaveBoundsPrefKey: PreferenceKey {
    static var defaultValue: [SaveBoundsPrefData] = []
    
    static func reduce(value: inout [SaveBoundsPrefData], nextValue: () -> [SaveBoundsPrefData]) {
        value.append(contentsOf: nextValue())
    }
    
    typealias Value = [SaveBoundsPrefData]
}

Why Bother with ViewModifier?

By now you may be wondering, what’s the point of ViewModifier then? Well, don’t give up on the little guy. SwiftUI has a place for everyone. It turns out, ViewModifier can do something that View extensions can’t. A ViewModifier can keep its own internal state, by declaring @State variables. Let’s work with an example

We are going to create a modifier, that when applied to a view, will make it selectable. When the modified view is tapped, a border will be drawn around it:

struct SelectOnTap: ViewModifier {
    let color: Color
    @State private var tapped: Bool = false
    
    func body(content: Content) -> some View {
        
        return content
            .padding(20)
            .overlay(RoundedRectangle(cornerRadius: 10).stroke(tapped ? color : Color.clear, lineWidth: 3.0))
            .onTapGesture {
                withAnimation(.easeInOut(duration: 0.3)) {
                    self.tapped.toggle()
                }
            }
    }
}

And we apply it to our views like this:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello World!").font(.largeTitle)
                .modifier(SelectOnTap(color: .purple))
            
            Circle().frame(width: 50, height: 50)
                .modifier(SelectOnTap(color: .green))
            
            LinearGradient(gradient: .init(colors: [Color.green, Color.blue]), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1)).frame(width: 50, height: 50)
                .modifier(SelectOnTap(color: .blue))
            
        }.padding(40)
    }
}

If you think about it, this can only be accomplished with a ViewModifier. We cannot use a view extension, because it cannot keep track on whether it has been tapped or not. And we cannot use a custom view, because the code would not be reusable. The only way to avoid using ViewModifier, is if we keep the tapped status outside the view extension. But that’s no fun. We would be splitting the logic half inside the view extension and half in the calling view.

Still, this article was about View extensions, wasn’t it? If you don’t mind writing one more line of code, we could wrap our ViewModifier inside a View extension.

extension View {
    func selectOnTap(_ color: Color) -> some View { modifier(SelectOnTap(color: color)) }
}

And now everyone’s happy. Let’s change the calling code:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello World!").font(.largeTitle)
                .selectOnTap(.purple)
            
            Circle().frame(width: 50, height: 50)
                .selectOnTap(.green)
            
            LinearGradient(gradient: .init(colors: [Color.green, Color.blue]), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1)).frame(width: 50, height: 50)
                .selectOnTap(.blue)
            
        }.padding(40)
    }
}

If you would like to expand your knowledge on ViewModifer, Majid’s Blog has a nice article here.

Summary

In this article, we’ve seen the potential of using view extensions. They can really improve code readability, accelerate development and reduce potential bugs. With a little creativity, there are tons of application for this simple technique. Feel free to comment below and follow me on twitter to be notified when new articles are out. Until next time…

7 thoughts on “View Extensions for Better Code Readability”

  1. Hello, why can’t we just function like this for retrieving CGRect of the view. What problems may it raise?
    func cgRect(closure: @escaping (CGRect) -> Void) -> some View {
    self.background(GeometryReader { proxy in
    Color
    .clear
    .onAppear { closure(proxy.frame(in: CoordinateSpace.global)) }
    })
    }

    Reply
    • Hi Sergey, the problem with that approach, is that the closure will not be called again when the view changes its size. For example, if you rotate the device, or some other factor affects the size of your view.

      Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close