This article originally appeared on my blog [Elbow’s Swift Notepad]

Now in its third year, SwiftUI offers more native functionality than it did when it started, but there are still plenty of things that can’t be done directly with native SwiftUI code. Developers will still have to rely on UIKit code in SwiftUI for quite some time. Fortunately, SwiftUI provides a convenient way for developers to wrap UIKit views (or controllers) into SwiftUI views.

This article will explain the following points by wrapping UITextField:

  • How to use UIKit view in SwiftUI
  • How to make your UIKit wrapper view SwiftUI style
  • UIKit view in SwiftUI

If you are already on how to useUIViewRepresentableI have a handle on it. I can get it straight fromSwiftUI stylizedPart of the reading

basis

Before demonstrating the wrapping code in detail, let’s cover some basics related to using UIKit views in SwiftUI.

Don’t worry if you can understand the following immediately, there will be more to help you get the hang of it later in the demo.

The life cycle

One of the main differences between SwiftUI and UIKit and AppKit is that SwiftUI views are value types, not specific references to what is drawn on the screen. In SwiftUI, developers create descriptions for views without actually rendering them.

In UIKit (or AppKit), views (or view controllers) have explicit life cycle nodes, such as vidwDidload, loadView, viewWillAppear, didAddSubView, didMoveToSuperview, etc., They essentially act as hooks that allow developers to execute a piece of logic in response to events given by the system.

SwiftUI views, which themselves do not have a clear (appropriately described) lifecycle, are values and declarations. SwiftUI provides several modifiers to implement behavior similar to UIKit’s hook methods. For example, onAppear behaves very similar to viewWillAppear. Very different from UIKit’s hook methods, onAppear and onDisappear are declared on the parent view of the current view.

When wrapping UIKit view as SwiftUI’s view, we need to understand the difference between the two life cycles. Instead of trying to find an exact corresponding method, we need to think about how to call UIKit view from SwiftUI’s perspective.

UIViewRepresentable agreement

Wrapping UIView in SwiftUI is as simple as creating a structure that complies with the UIViewRepresentable protocol.

UIViewControllerRepresentable corresponding UIViewController, corresponding NSView NSViewRepresentable, Corresponding NSViewController NSViewControllerRepresentable. The internal structure and implementation logic are consistent.

UIViewrepresentable protocol is not complex and consists of makeUIView, updateUIView, Orientation UiView, and makeCoordinator. MakeUIView and updateUIView are methods that must be implemented.

UIViewRepresentable itself complies with the View protocol, so SwiftUI will treat any structure that complies with that protocol as a normal SwiftUI View. The internal life cycle of UIViewRepresentable differs from that of standard SwiftUI views, while being used for specific purposes.

  • makeCoordinator

    If we declare a Coordinator, the UIViewRepresentable view first creates an instance of it after initialization so that it can be invoked in another method. Coordinator defaults to Void, and the method is called only once in the life of the UIViewRepresentable, so only one instance of the Coordinator is created.

  • makeUIView

    Create a UIKit view instance to wrap. This method is called only once in the life of the UIViewRepresentable.

  • updateUIView

    SwiftUI updates the parts of the interface affected by changes in the application’s State. When an injection dependency changes in UIViewRepresentable view, SwiftUI calls updateUIView. The timing of the invocation is the same as that of the body of the standard SwiftUI view, with the biggest difference being that the body is called to calculate a value, whereas updateView is called only to notify UIViewRepresentable View of changes in its dependencies, and whether it needs to react to those changes is up to the developer.

    This method is called several times during the life of UIViewRepresentable until the view is removed from the view tree (more accurately, switched to another branch of the view tree that does not contain the view).

    After the makeUIVIew is executed, update EUivew must be executed once

  • dismantleUIView

    SwiftUI calls “Orientation tleUIView” before the UIViewRepresentable view is removed from the view tree. Typically, this method allows for cleanup operations such as u removal of observers. “Sees” the entire TLEUIView as a type method.

The following code creates a circle chrysanthemum like the ProgressView:

struct MyProgrssView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let view = UIActivityIndicatorView()
        view.startAnimating()
        return view
    }

    func updateUIView(_ uiView: UIActivityIndicatorView.context: Context){}}struct Demo: View {
    var body: some View {
            MyProgrssView()}}Copy the code

Black box

When drawing the screen, SwiftUI evaluates the body of the view starting at the top of the view tree, and if it also contains subviews, it evaluates recursively until the final result is reached. But SwiftUI can’t really make an infinite number of calls to draw a view, so it has to shorten the recursion somehow. To end the recursion SwiftUI includes many primitive types. When SwiftUI recurses to these primitive types, the recursion ends and it no longer cares about the body of the primitive type, leaving the primitive type to handle the area it manages.

The SwiftUI framework marks the View as primitive by defining the body as Never. UIViewRepresentable happens to be one of these (Text, ZStack, Color, List, etc., are also so-called primitive types).

public protocol UIViewRepresentable : View where Self.Body= =Never
Copy the code

In fact, almost all primitive types are low-level wrappers around UIKit or AppKit.

UIViewRepresentable as a primitive type, SwiftUI knows very little about its internals (because it doesn’t need to care). Typically the developer needs to do some work in the Coordinator of UIViewRepresentable view to ensure communication between the two frameworks (SwiftUI and UIKit) code.

The coordinator

Apple frameworks love the term Coordinator. There is a Coordinator design pattern in UIKit development and a persistent storage Coordinator in Core Data. The coordinator in UIViewRepresentable is completely different from these concepts and serves the following functions:

  • Implements a proxy for UIKit views

    UIKit components typically rely on proxies, objects that respond to events that occur elsewhere, to implement some function. For example, UIKit tells us to attach a proxy object to the Text Field view, and when the user enters any, when the user presses the return key, the corresponding method in that proxy object will be called. By declaring the coordinator as a proxy object corresponding to the UIKit view, we can implement the desired proxy methods in it.

  • Communicate with SwiftUI framework

    Previously, we mentioned UIViewRepresentable as a primitive type that takes the initiative to communicate more with the SwiftUI framework or other views. In the coordinator, we can report the state within UIKit views to the SwiftUI framework or other required modules via bidirectional Binding, notificationCenter, or other single data streams such as Redux mode. You can also register an observer, subscribe to Publisher, and get the information you need.

  • Handles complex logic in UIKit views

    In UIKit development, business logic is usually placed in UIViewController. SwiftUI does not have the concept of Controller, and view is just a presentation of state. For some UIKit modules that implement complex functions, it would be very difficult to completely strip the business logic from the SwiftUI model. Therefore, the implementation code of the business logic that cannot be stripped is placed in the coordinator, close to the broker method, for easy coordination and management with each other.

Packaging UITextField at

In this section we will use this knowledge to implement a simple UITextField wrapper view, TextFieldWrapper.

Version 1.0

In the first release, we implemented a function similar to the following native code:

TextField("name:",text:$name)
Copy the code

View source code

We’ve created an instance of UITextField in the makeUIView, and we’ve set it to placeholder and text. In the preview on the right, we can see that the placeholder will display normally, and if you put text in it, it will behave exactly like the TextField.

With.border, we see that the view size of TextFieldWrapper is not as large as expected because the UITextField takes up all the available space by default without constraint. The demo code for UIActivityIndicatorView above does not show this. Therefore, for different UIKit components, we need to understand their default Settings and set constraints on them as appropriate.

Add the following statement to the makeUIView to make the text input box the expected size:

        textfield.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textfield.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
Copy the code

Modify the Demo view slightly by adding Text(“name:\(name)”) under the.padding(). If TextField behaves normally, when we type any Text into it, the Text below should display the corresponding content, but in our current version of the code, it does not behave as expected.

Let’s look at the code again.

Although we declared a Binding

text and assigned it to the TextField in the makeUIView, UITextField does not automatically pass what we typed back to the Binding

text. This results in the name in the Demo view not changing due to text entry.

TextField (_ textField: UITextfield, shouldChangeCharactersIn range: NSRange, replacementString String: string) -> Bool proxy method. So we need to create a coordinator and implement this method in the coordinator, passing the input to the name variable in the Demo view.

Create coordinator:

extension TextFieldWrapper{
    class Coordinator:NSObject.UITextFieldDelegate{
        @Binding var text:String
        init(text:Binding<String>){
            self._text = text
        }

        func textField(_ textField: UITextField.shouldChangeCharactersIn range: NSRange.replacementString string: String) -> Bool {
            if let text = textField.text as NSString? {
                let finaltext = text.replacingCharacters(in: range, with: string)
                self.text = finaltext as String
            }
            return true}}}Copy the code

We need to send back data in the textField method, so the Coordinator also needs to use Binding

, so that the operation on the text is the operation on the name in the Demo view.

If Coordinator in UIViewRepresentable view is not Void, an instance of it must be created by makeCoordinator. Add the following code to TextFieldWrapper:

    func makeCoordinator() -> Coordinator {
        .init(text: $text)
    }
Copy the code

Finally, in the makeUIView add:

	textfield.delegate = context.coordinator
Copy the code

UITextField looks up and invokes the corresponding proxy method in the coordinator after a specific event occurs.

View source code

At this point, the UITextField wrapper we created matches the presentation behavior of the native TextField.

Are you sure?

Modify the Demo view again and change it to:

struct Demo: View {
    @State var name: String = ""
    var body: some View {
        VStack {
            TextFieldWrapper("name:", text: $name)
                .border(.blue)
                .padding()
            Text("name:\(name)")
            Button("Random Name"){
                name = String(Int.random(in: 0.100)}}}}Copy the code

As expected of the native TextField, when we press the Random Name button, the Text in both Text and TextFieldWrapper should be changed to String(Int. Random (in: 0… 100)), but if you test using the code above, the text in TextFieldWrapper does not change.

In the makeUIView, we use textField. text = text to get the value of name in the Demo view, but the makeUIView will only be executed once. When clicking on Random Name causes the Name to change, SwiftUI will call updateUIView without doing any processing in it. Just add the following code to updateUIVIew:

    func updateUIView(_ uiView: UIViewType.context: Context) {
        DispatchQueue.main.async {
            uiView.text = text
        }
    }
Copy the code

The makeUIView method has a context as an argument: Context, through which we can access Coordinator (custom Coordinator), Transaction (how to handle state updates, animation mode), and environment (the set of environment values for the current view). We will demonstrate its use with an example later. The context is also accessible in the updateUIVIew and the entire TLEUIView. The updataUIView argument _ uiView:UIViewType is the UIKit view instance that we created in the makeUIVIew.

View source code

Now our TextFieldWrapper behaves exactly like TextField.

Version 2.0 — Add Settings

Based on the first version, we will add configuration Settings for Color, FONT, clearButtonMode, onCommit, and onEditingChanged for TextFieldWrapper.

To minimize the complexity of routines, we use UIColor, UIFont as configuration types. Converting SwiftUI Color and Font to UIKit versions will add a significant amount of code.

Color, font, and our new clearButtonMode do not require bidirectional data streaming, so there is no need to use Binding, just respond to their changes in the updateView.

OnCommit and onEditingChanged correspond to the UITextField agent’s textFieldShouldReturn, textFieldDidBeginEditing, and textFieldDidEndEditing methods, respectively. We need to implement these methods separately in the coordinator and call the corresponding blocks.

First modify the coordinator:

extension TextFieldWrapper {
    class Coordinator: NSObject.UITextFieldDelegate {
        @Binding var text: String
        var onCommit: () -> Void
        var onEditingChanged: (Bool) - >Void
        init(text: Binding<String>,
             onCommit: @escaping() - >Void.onEditingChanged: @escaping (Bool) - >Void) {
            self._text = text
            self.onCommit = onCommit
            self.onEditingChanged = onEditingChanged
        }

        func textField(_ textField: UITextField.shouldChangeCharactersIn range: NSRange.replacementString string: String) -> Bool {
            if let text = textField.text as NSString? {
                let finaltext = text.replacingCharacters(in: range, with: string)
                self.text = finaltext as String
            }
            return true
        }

        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            onCommit()
            return true
        }

        func textFieldDidBeginEditing(_ textField: UITextField) {
            onEditingChanged(true)}func textFieldDidEndEditing(_ textField: UITextField.reason: UITextField.DidEndEditingReason) {
            onEditingChanged(false)}}}Copy the code

Modify TextFieldWrapper:

struct TextFieldWrapper: UIViewRepresentable {
    init(_ placeholder: String.text: Binding<String>,
         color: UIColor = .label,
         font: UIFont = .preferredFont(forTextStyle: .body),
         clearButtonMode:UITextField.ViewMode = .whileEditing,
         onCommit: @escaping() - >Void = {},
         onEditingChanged: @escaping (Bool) - >Void = { _ in})
    {
        self.placeholder = placeholder
        self._text = text
        self.color = color
        self.font = font
        self.clearButtonMode = clearButtonMode
        self.onCommit = onCommit
        self.onEditingChanged = onEditingChanged
    }

    let placeholder: String
    @Binding var text: String
    let color: UIColor
    let font: UIFont
    let clearButtonMode: UITextField.ViewMode
    var onCommit: () -> Void
    var onEditingChanged: (Bool) - >Void

    typealias UIViewType = UITextField
    func makeUIView(context: Context) -> UIViewType {
        let textfield = UITextField()
        textfield.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textfield.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        textfield.placeholder = placeholder
        textfield.delegate = context.coordinator
        return textfield
    }

    func updateUIView(_ uiView: UIViewType.context: Context) {
        DispatchQueue.main.async {
            uiView.text = text
            uiView.textColor = color
            uiView.font = font
            uiView.clearButtonMode = clearButtonMode
        }
    }

    func makeCoordinator(a) -> Coordinator{.init(text: $text,onCommit: onCommit,onEditingChanged: onEditingChanged)
    }
}

Copy the code

Modifying the Demo view:

struct Demo: View {
    @State var name: String = ""
    @State var color: UIColor = .red
    var body: some View {
        VStack {
            TextFieldWrapper("name:",
                             text: $name,
                             color: color,
                             font: .preferredFont(forTextStyle: .title1),
                             clearButtonMode: .whileEditing,
                             onCommit: { print("return") },
                             onEditingChanged: { editing in print("isEditing \(editing)") })
                .border(.blue)
                .padding()
            Text("name:\(name)")
            Button("Random Name") {
                name = String(Int.random(in: 0.100))}Button("Change Color") {
                color = color = = .red ? .label : .red
            }
        }
    }
}

struct TextFieldWrapperPreview: PreviewProvider {
    static var previews: some View {
        Demo()}}Copy the code

View source code

SwiftUI stylized

We not only implemented the font and color Settings, but also added the clearButtonMode Settings that the native TextField did not have. Step by step, you can add more Settings to TextFieldWrapper to give it more functionality.

There’s something wrong with the code, right? !

As the function configuration increases, the above code will become more and more inconvenient to use. How to implement the chain call similar to the native TextFiled? Such as:

		       TextFieldWrapper("name:",text:$name)
                .clearMode(.whileEditing)
								.onCommit{print("commit")}
                .foregroundColor(.red)
                .font(.title)
                .disabled(allowEdit)

Copy the code

In this section, we will rewrite the configuration code to implement THE UIKit wrapper style SwiftUI.

This section is based on the code at the end of version 1.0.

The so-called SwfitUI stylization is more like chain calls for functional programming. Pass multiple operations through the dot (.) Link together for readability. As Swift, which treats functions like first-class citizens, implementing the above chain calls is very convenient. But there are a few caveats:

  • How to change the value of a View
  • How to handle returned types (to ensure that the call chain continues to work)
  • How do I leverage and interact with existing SwiftUI framework data

For a fuller demonstration, the following example uses a different approach. In actual use, you can choose an appropriate solution according to actual requirements.

foregroundColor

ForegroundColor is often used in SwiftUI to set the foregroundColor, as in the following code:

            VStack{
                Text("hello world")
                    .foregroundColor(.red)
            }
            .foregroundColor(.blue)
Copy the code

I don’t know if you know the difference between the two foregroundcolors.

extension Text{
	  public func foregroundColor(_ color: Color?). -> Text
}

extension View{
  	public func foregroundColor(_ color: Color?). -> some View
}
Copy the code

The method name is the same, but the object is different. Text attempts to get the foregroundColor (for View) from the current environment only if it is not set for itself. The original TextFiled does not have its own foregroundColor, but we currently have no way to get the environment value set by SwiftUI for View foregroundColor (estimated to be), so we can use Text. Create a proprietary foregroundColor for TextFieldWrapper.

Add a variable to TextFieldWrapper

private var color:UIColor = .label
Copy the code

Add it to updateUIView

uiView.textColor = color
Copy the code

Setting and configuration methods:

extension TextFieldWrapper {
    func foregroundColor(_ color:UIColor) -> Self{
        var view = self
        view.color = color
        return view
    }
}
Copy the code

View source code

It’s that simple. Now we can use.foreground(.red) to set the text color of the TextFieldWrapper.

This is a common way to add extensions to a particular view type. It has the following two advantages:

  • useprivateWithout exposing configuration variables
  • Still returns a specific type of view, which helps maintain chain stability

We can do almost all chain extensions in this way. If there are many extensions, you can use the following methods to further clarify and simplify the code:

    extension View {
        func then(_ body: (inout Self) - >Void) -> Self {
            var result = self
            body(&result)
            return result
        }
    }

		func foregroundColor(_ color:UIColor) -> Self{
        then{
            $0.color = color
        }
    }
Copy the code

disabled

SwiftUI presets a number of extensions for Views, much of which is passed level by level through EnvironmentValue. By responding directly to changes in the value of this environment, we can add configuration functionality to it without writing a specific TextFieldWrapper extension.

For example, View has an extension. Disabled, which is normally used to control the operability of interactive controls (.disable corresponds to isEnabled).

Add to TextFieldWrapper:

@Environment(\.isEnabled) var isEnabled
Copy the code

In updateUIView add:

uiView.isEnabled = isEnabled
Copy the code

With just two statements, TextFieldWrapper can directly use the Disable extension to View to control whether it can input data.

Remember the context from the previous article? We can get the value of the context directly from the context. So supporting the native View extension is a step down.

You don’t need to add @environemnt, just add a statement to updateUIView:

uiView.isEnabled = context.environment.isEnabled
Copy the code

View source code

At the time of writing this article, running this code under iOS15 beta will result in an AttributeGraph: Cycle Detected through attribute warning, which should be an iOS15 Bug, please ignore it yourself.

Setting by context is a very convenient way, the only thing that matters is that it changes the return value of the chain structure. Therefore, chained methods after this node can only be set for the View, like the foregroundColor we created earlier can only be placed before this node.

font

We can also create our own environment values to implement the configuration of TextFieldWrapper. For example, SwiftUI provides a font environment value of type FONT. In this example, we will create an environment value for UIFont.

Create environment value myFont:

struct MyFontKey:EnvironmentKey{
    static var defaultValue: UIFont?
}

extension EnvironmentValues{
    var myFont:UIFont? {get{self[MyFontKey.self]}
        set{self[MyFontKey.self] = newValue}
    }
}

Copy the code

In updateUIVIew add:

uiView.font = context.environment.myFont
Copy the code

The font method can be written in several ways:

  • withforgroundColorThe same toTextFieldWrapperextend
    func font(_ font:UIFont) -> some View{
        environment(\.myFont, font)
    }
Copy the code
  • rightViewextend
extension View {
    func font(_ font:UIFont?). -> some View{
        environment(\.myFont, font)
    }
}
Copy the code

In both cases, the return value of the chained node is no longer TextFieldWrapper and should be followed by an extension to the View.

View source code

onCommit

In the version 2 code, we added the onCommit setting to TextFieldWrapper, which is triggered when the user types return. In this case, we will add a modifiable version of onCommit that does not need to be passed through the coordinator constructor.

I’ve done all the tricks in this example before, but the only caveat is that in the updateUIView, you can pass

context.coordinator.onCommit = onCommit
context.coordinator.onEditingChanged = onEditingChanged
Copy the code

Change variables in the coordinator. This is a very effective means of communicating between SwiftUI and the coordinator.

View source code

Avoid abusing UIKit wrappers

While using UIKit or AppKit in SwiftUI is not a hassle, think twice before wrapping a UIKit control (especially if you already have the official SwiftUI native solution).

Apple’s ambition for SwiftUI is not only to bring a declarative + responsive programming experience to developers, but also to invest heavily in SwiftUI across devices and platforms (the Apple ecosystem).

Apple has made a lot of optimizations for each native control (like TextField) for different platforms (iOS, macOS, tvOS, watchOS). This is something that is hard for anyone else to do on their own. Therefore, consider the following points before you repackage a system control for a specific function.

The official original plan

SwiftUI has developed very quickly over the years, with every release adding a lot of new features, perhaps the ones you need have already been added. Apple’s documentation support for SwiftUI has improved considerably in the last two years, but it’s still not satisfactory. As the developer of SwiftUI, I recommend you to buy A copy of Javier’s A Companion for SwiftUI. The app provides a far richer and clearer guide to the SwiftUI API than the official SwiftUI API. Use the app and you will find that SwiftUI offers so many functions.

Combine solutions with native methods

Before SwiftUI 3.0, SwiftUI did not provide searchBar. There are two routes, one is to wrap a UIKit UISearchbar by itself, and the other is to combine a Searchbar by using SwiftUI’s native method. In most cases, both approaches work satisfactorily. However, the searchbar created in the native way is more flexible in composition and supports using LocalizedString as placeholder. I personally would prefer to use a combination of solutions.

Many data types in SwiftUI do not officially provide a solution for converting to other framework types. For example, Color, Font. But these two can be converted with a little more code. LocalizedString can currently only be converted by abnormal means (using Mirror), and it is hard to guarantee that this conversion will last.

Introspect for SwiftUI

In the version 2 code, we added the clearButtonMode setting to TextFieldWrapper, which is the only setting we added that TextField does not currently support. However, it would be a mistake to wrap UITextField just to add this functionality.

Introspect attempts to find UIKit (or AppKit) components wrapped behind native controls by introspection. Most of the features that are not officially available in SwiftUI can be addressed using the methods provided by this extension library.

For example: The following code will add the clearButtonMode setting to the native TextField

        import Introspect
        extension TextField {
            func clearButtonMode(_ mode:UITextField.ViewMode) -> some View{
                introspectTextField{ tf in
                    tf.clearButtonMode = mode
                }
            }
        }

        TextField("name:",text:$name)
           .clearButtonMode(.whileEditing)
Copy the code

conclusion

SwiftUI’s interoperability with UIKit and AppKit gives developers great flexibility. It’s easy to learn how to use it, but it’s really difficult to use it well. Sharing mutable state and complex interactions between UIKit and SwiftUI views is often quite complex, requiring us to build various bridge layers between the two frameworks.

This article doesn’t cover the topic of wrapping coordinators with complex logic to communicate with SwiftUI or Redux patterns, it’s too much to cover and probably needs to be covered in another article.

Hopefully this article has helped you learn and understand how to import UIKit components into SwiftUI.

This article originally appeared on my blog [Elbow’s Swift Notepad]