Creating Flexible UIViewRepresentable Types

I have been working on an app for the last month primarily using SwiftUI. In SwiftUI. after time you inevitably run into some of the missing view types and controls on views.

The only way to remedy this is to make your own View type and conform it to UIViewRepresentable. Ideally, with this I would like to make View types that mimic the behaviour and usage of native SwiftUI view types.

To be able to use the type as you would a regular View type, it should ideally be able to work with most/all of the view modifiers extensions that are available on the provided SwiftUI view types. In practice, the use of view modifiers in SwiftUI trickle down to child view types, but, for the most part, if you use your own UIViewRepresentable type, though it conforms to View, many modifiers will not affect it. This holds true whether the modifiers are called on the view directly or on a parent view.

This is where I really had to take a step back and think. Though I didn't really have a clue of how to make this work if a modifier is called on a parent view, I decided I could maybe do it if I didn't use the default implementations of the shipped view modifier extensions and rather used my own implementations on my UIViewRepresentable type.

To make this fluid, I wanted to be able to write my own implementation for something like .foregroundColor(_:) on my UIViewRepresentable type. To do this properly, I wanted to take a value of type Color as the argument to fit in with every other SwiftUI view type; this is where I encountered my first major roadblock. Since the type is backed by a UIView, to set any color on the view, I need the color as a UIColor. This, according to the extensive research I have done, seems to be a no go. For some reason that is beyond me, a UIColor can be converted to a Color but not visa versa. Moreover, you cannot access any of the properties of a value of Color (i.e. rgba components, color space, etc.). The only information you can get from a value of Color is by dumping it. Here is my attempt at implementing an initializer on UIColor that takes a value of type Color using the aforementioned strategy. I decided not to use this to convert it to UIColor since it is based on implementation details of Color and is subject to change in the future. And even if I were to put that aside, the contents of the dumped color itself differ depending on the way it was initialized, on whether an .opacity(_:) modifier was applied, and probably some other things I haven't noticed. I was thinking that maybe if you made a SwiftUI view using a particular Color value then maybe from some magic trickery it would be found as some property somewhere on the view wrapped in a UIHostingController, but I have not found a way to do this. This forces you to use UIColor rather than Color on all UIKit-backed view types and subsequent view modifier extensions on the view type. This problem extends much further than just ColorUIColor, but to FontUIFont too (I'm sure there are many more of these pairs that I haven't run into yet, but it seems ot be a common theme of sorts).

I am unsatisfied with my current attempts at this as their UIKit-backings fundamentally limit their use and cause them behave differently than SwiftUI views, making its UIKit implementation an important aspect of the type (even though it really shouldn't be). Here is one of my attempts at this that aims to bring UITextView's functionality to SwiftUI:

struct MultilineTextView: View {
    @Binding var text: String
    @Binding var isEditing: Bool
    var onEditingChanged: (Bool) -> Void = { _ in }
    var onCommit: () -> Void = { }
    
    private var returnKeyType: UIReturnKeyType?
    private var font: UIFont = UIFont.preferredFont(forTextStyle: .body)
    private var foregroundColor: UIColor?
    private var textAlignment: NSTextAlignment?
    private var clearsOnInsertion: Bool = false
    private var contentType: UITextContentType?
    private var autocorrection: UITextAutocorrectionType = .default
    private var autocapitalization: UITextAutocapitalizationType = .sentences
    private var lineLimit: Int?
    private var truncationMode: NSLineBreakMode?
    private var isSecure: Bool = false
    private var isEditable: Bool = true
    private var isSelectable: Bool = true
    private var isScrollingEnabled: Bool = false
    private var isUserInteractionEnabled: Bool = true
    
    @State private var dynamicHeight: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize
    @Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
    
    init(text: Binding<String>,
         isEditing: Binding<Bool>,
         onEditingChanged: @escaping (Bool) -> Void = { _ in },
         onCommit: @escaping () -> Void = { })
    {
        self._text = text
        self._isEditing = isEditing
        self.onEditingChanged = onEditingChanged
        self.onCommit = onCommit
    }
    
    var body: some View {
        UITextViewWrapper(text: $text,
                          isEditing: $isEditing,
                          calculatedHeight: $dynamicHeight,
                          returnKeyType: returnKeyType,
                          font: font,
                          foregroundColor: foregroundColor,
                          textAlignment: textAlignment,
                          clearsOnInsertion: clearsOnInsertion,
                          contentType: contentType,
                          autocorrection: autocorrection,
                          autocapitalization: autocapitalization,
                          lineLimit: lineLimit,
                          truncationMode: truncationMode,
                          isSecure: isSecure,
                          isEditable: isEditable,
                          isSelectable: isSelectable,
                          isScrollingEnabled: isScrollingEnabled,
                          isUserInteractionEnabled: isUserInteractionEnabled,
                          onEditingChanged: onEditingChanged,
                          onCommit: onCommit)
        .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
    }
}

extension MultilineTextView {
    func foregroundColor(_ color: UIColor?) -> some View {
        var view = self
        view.foregroundColor = color
        return view
    }
    
    func font(_ font: UIFont?) -> some View {
        var view = self
        view.font = font ?? UIFont.preferredFont(forTextStyle: .body)
        return view
    }
    
    func multilineTextAlignment(_ alignment: TextAlignment) -> some View {
        var view = self
        switch alignment {
        case .leading:
            view.textAlignment = layoutDirection ~= .leftToRight ? .left : .right
        case .trailing:
            view.textAlignment = layoutDirection ~= .leftToRight ? .right : .left
        case .center:
            view.textAlignment = .center
        }
        return view
    }
    
    func clearOnInsertion(_ value: Bool) -> some View {
        var view = self
        view.clearsOnInsertion = value
        return view
    }
    
    func textContentType(_ textContentType: UITextContentType?) -> some View {
        var view = self
        view.contentType = textContentType
        return view
    }
    
    func disableAutocorrection(_ disable: Bool?) -> some View {
        var view = self
        if let disable = disable {
            view.autocorrection = disable ? .no : .yes
        } else {
            view.autocorrection = .default
        }
        return view
    }
    
    func autocapitalization(_ style: UITextAutocapitalizationType) -> some View {
        var view = self
        view.autocapitalization = style
        return view
    }
    
    func isSecure(_ isSecure: Bool) -> some View {
        var view = self
        view.isSecure = isSecure
        return view
    }

    func isEditable(_ isEditable: Bool) -> some View {
        var view = self
        view.isEditable = isEditable
        return view
    }
    
    func isSelectable(_ isSelectable: Bool) -> some View {
        var view = self
        view.isSelectable = isSelectable
        return view
    }
    
    func enableScrolling(_ isScrollingEnabled: Bool) -> some View {
        var view = self
        view.isScrollingEnabled = isScrollingEnabled
        return view
    }
    
    func disabled(_ disabled: Bool) -> some View {
        var view = self
        view.isUserInteractionEnabled = disabled
        return view
    }
    
    
    func returnKey(_ style: UIReturnKeyType?) -> some View {
        var view = self
        view.returnKeyType = style
        return view
    }
    
    func lineLimit(_ number: Int?) -> some View {
        var view = self
        view.lineLimit = number
        return view
    }
    
    func truncationMode(_ mode: Text.TruncationMode) -> some View {
        var view = self
        switch mode {
        case .head: view.truncationMode = .byTruncatingHead
        case .tail: view.truncationMode = .byTruncatingTail
        case .middle: view.truncationMode = .byTruncatingMiddle
        @unknown default:
            fatalError("Unknown text truncation mode")
        }
        return body
    }
}


fileprivate struct UITextViewWrapper: UIViewRepresentable {
    @Binding var text: String
    @Binding var isEditing: Bool
    @Binding var calculatedHeight: CGFloat

    var onEditingChanged: (Bool) -> Void = { _ in }
    var onCommit: () -> Void = { }
    
    private var returnKeyType: UIReturnKeyType?
    private var font: UIFont
    private var foregroundColor: UIColor?
    private var textAlignment: NSTextAlignment?
    private var clearsOnInsertion: Bool = false
    private var contentType: UITextContentType?
    private var autocorrection: UITextAutocorrectionType = .default
    private var autocapitalization: UITextAutocapitalizationType = .sentences
    private var lineLimit: Int?
    private var truncationMode: NSLineBreakMode?
    private var isSecure: Bool = false
    private var isEditable: Bool = true
    private var isSelectable: Bool = true
    private var isScrollingEnabled: Bool = false
    private var isUserInteractionEnabled: Bool = true
    
    init(text: Binding<String>,
         isEditing: Binding<Bool>,
         calculatedHeight: Binding<CGFloat>,
         returnKeyType: UIReturnKeyType?,
         font: UIFont,
         foregroundColor: UIColor?,
         textAlignment: NSTextAlignment?,
         clearsOnInsertion: Bool,
         contentType: UITextContentType?,
         autocorrection: UITextAutocorrectionType,
         autocapitalization: UITextAutocapitalizationType,
         lineLimit: Int?,
         truncationMode: NSLineBreakMode?,
         isSecure: Bool,
         isEditable: Bool,
         isSelectable: Bool,
         isScrollingEnabled: Bool,
         isUserInteractionEnabled: Bool,
         onEditingChanged: @escaping (Bool) -> Void,
         onCommit: @escaping () -> Void)
    {
        self._text = text
        self._isEditing = isEditing
        self._calculatedHeight = calculatedHeight
        self.returnKeyType = returnKeyType
        self.font = font
        self.foregroundColor = foregroundColor
        self.textAlignment = textAlignment
        self.clearsOnInsertion = clearsOnInsertion
        self.contentType = contentType
        self.autocorrection = autocorrection
        self.autocapitalization = autocapitalization
        self.lineLimit = lineLimit
        self.truncationMode = truncationMode
        self.isSecure = isSecure
        self.isEditable = isEditable
        self.isSelectable = isSelectable
        self.isScrollingEnabled = isScrollingEnabled
        self.isUserInteractionEnabled = isUserInteractionEnabled
        self.onEditingChanged = onEditingChanged
        self.onCommit = onCommit
    }
    
    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.delegate = context.coordinator
        view.backgroundColor = .clear
        
        view.textContainerInset = UIEdgeInsets.zero
        view.textContainer.lineFragmentPadding = 0
        if let returnKeyType = returnKeyType {
            view.returnKeyType = returnKeyType
        }
        view.font = font
        view.textColor = foregroundColor
        if let textAlignment = textAlignment {
            view.textAlignment = textAlignment
        }
        view.clearsOnInsertion = clearsOnInsertion
        view.textContentType = contentType
        view.autocorrectionType = autocorrection
        view.autocapitalizationType = autocapitalization
        view.isSecureTextEntry = isSecure
        view.isEditable = isEditable
        view.isSelectable = isSelectable
        view.isScrollEnabled = isScrollingEnabled
        view.isUserInteractionEnabled = isUserInteractionEnabled
        if let lineLimit = lineLimit {
            view.textContainer.maximumNumberOfLines = lineLimit
        }
        if let truncationMode = truncationMode {
            view.textContainer.lineBreakMode = truncationMode
        }
        view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return view
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
        if isEditing {
            uiView.becomeFirstResponder()
        } else {
            uiView.resignFirstResponder()
        }
        UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text, isEditing: $isEditing, calculatedHeight: $calculatedHeight, onChanged: onEditingChanged, onDone: onCommit)
    }
    
    
    fileprivate static func recalculateHeight(view: UIView, result: Binding<CGFloat>) {
        let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
        if result.wrappedValue != newSize.height {
            DispatchQueue.main.async {
                result.wrappedValue = newSize.height // !! must be called asynchronously
            }
        }
    }
    
    final class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String
        @Binding var isEditing: Bool
        @Binding var calculatedHeight: CGFloat
        var onChanged: (Bool) -> Void
        var onDone: () -> Void
        
        init(text: Binding<String>, isEditing: Binding<Bool>, calculatedHeight: Binding<CGFloat>, onChanged: @escaping (Bool) -> Void, onDone: @escaping () -> Void) {
            self._text = text
            self._isEditing = isEditing
            self._calculatedHeight = calculatedHeight
            self.onChanged = onChanged
            self.onDone = onDone
        }
        
        func textViewDidChange(_ uiView: UITextView) {
            text = uiView.text
            onChanged(true)
            UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
        }
        
        func textViewDidBeginEditing(_ textView: UITextView) {
            onChanged(false)
        }
        
        func textViewDidEndEditing(_ textView: UITextView) {
            onDone()
        }
        
        
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            if text == "\n" {
                isEditing = false
                //textView.resignFirstResponder()
                return false
            }
            return true
        }
    }
    
}

Here is another type I made to the same affect but for UITextField:

struct TextFieldView: UIViewRepresentable {

    @Binding var text: String
    @Binding var isEditing: Bool

    var didBeginEditing: () -> Void = { }
    var didChange: () -> Void = { }
    var didEndEditing: () -> Void = { }

    private var font: UIFont?
    private var foregroundColor: UIColor?
    private var accentColor: UIColor?
    private var textAlignment: NSTextAlignment?
    private var contentType: UITextContentType?

    private var autocorrection: UITextAutocorrectionType = .default
    private var autocapitalization: UITextAutocapitalizationType = .sentences
    private var keyboardType: UIKeyboardType = .default
    private var returnKeyType: UIReturnKeyType = .default

    private var isSecure: Bool = false
    private var isUserInteractionEnabled: Bool = true
    private var clearsOnBeginEditing: Bool = false

    @Environment(\.layoutDirection) private var layoutDirection: LayoutDirection

    init(text: Binding<String>,
         isEditing: Binding<Bool>,
         didBeginEditing: @escaping () -> Void = { },
         didChange: @escaping () -> Void = { },
         didEndEditing: @escaping () -> Void = { })
    {
        self._text = text
        self._isEditing = isEditing
        self.didBeginEditing = didBeginEditing
        self.didChange = didChange
        self.didEndEditing = didEndEditing
    }

    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()

        textField.delegate = context.coordinator

        textField.text = text
        textField.font = font
        textField.textColor = foregroundColor
        if let textAlignment = textAlignment {
            textField.textAlignment = textAlignment
        }
        if let contentType = contentType {
            textField.textContentType = contentType
        }
        if let accentColor = accentColor {
            textField.tintColor = accentColor
        }
        textField.autocorrectionType = autocorrection
        textField.autocapitalizationType = autocapitalization
        textField.keyboardType = keyboardType
        textField.returnKeyType = returnKeyType

        textField.clearsOnBeginEditing = clearsOnBeginEditing
        textField.isSecureTextEntry = isSecure
        textField.isUserInteractionEnabled = isUserInteractionEnabled
        if isEditing {
            textField.becomeFirstResponder()
        }

        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)


        textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)

        
        return textField
    }

    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text
        if isEditing {
            uiView.becomeFirstResponder()
        } else {
            uiView.resignFirstResponder()
        }
    }

    
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text,
                           isEditing: $isEditing,
                           didBeginEditing: didEndEditing,
                           didChange: didChange,
                           didEndEditing: didEndEditing)
    }

    final class Coordinator: NSObject, UITextFieldDelegate {
        @Binding var text: String
        @Binding var isEditing: Bool

        var didBeginEditing: () -> Void
        var didChange: () -> Void
        var didEndEditing: () -> Void

        init(text: Binding<String>, isEditing: Binding<Bool>, didBeginEditing: @escaping () -> Void, didChange: @escaping () -> Void, didEndEditing: @escaping () -> Void) {
            self._text = text
            self._isEditing = isEditing
            self.didBeginEditing = didBeginEditing
            self.didChange = didChange
            self.didEndEditing = didEndEditing
        }
        
        func textFieldDidBeginEditing(_ textField: UITextField) {
            DispatchQueue.main.async {
                if !self.isEditing {
                    self.isEditing = true
                }
                self.didEndEditing()
            }
        }

        @objc func textFieldDidChange(_ textField: UITextField) {
            text = textField.text ?? ""
            didChange()
        }

        func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
            DispatchQueue.main.async {
                if self.isEditing {
                    self.isEditing = false
                }
                self.didEndEditing()
            }
        }

        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            isEditing = false
            return false
        }
    }

}


extension TextFieldView {
    func font(_ font: UIFont?) -> some View {
        var view = self
        view.font = font
        return view
    }

    func foregroundColor(_ color: UIColor?) -> some View {
        var view = self
        view.foregroundColor = color
        return view
    }

    func accentColor(_ accentColor: UIColor?) -> some View {
        var view = self
        view.accentColor = accentColor
        return view
    }

    func multilineTextAlignment(_ alignment: TextAlignment) -> some View {
        var view = self
        switch alignment {
        case .leading:
            view.textAlignment = layoutDirection ~= .leftToRight ? .left : .right
        case .trailing:
            view.textAlignment = layoutDirection ~= .leftToRight ? .right : .left
        case .center:
            view.textAlignment = .center
        }
        return view
    }

    func textContentType(_ textContentType: UITextContentType?) -> some View {
        var view = self
        view.contentType = textContentType
        return view
    }

    func disableAutocorrection(_ disable: Bool?) -> some View {
        var view = self
        if let disable = disable {
            view.autocorrection = disable ? .no : .yes
        } else {
            view.autocorrection = .default
        }
        return view
    }

    func autocapitalization(_ style: UITextAutocapitalizationType) -> some View {
        var view = self
        view.autocapitalization = style
        return view
    }

    func keyboardType(_ type: UIKeyboardType) -> some View {
        var view = self
        view.keyboardType = type
        return view
    }

    func returnKeyType(_ type: UIReturnKeyType) -> some View {
        var view = self
        view.returnKeyType = type
        return view
    }

    func isSecure(_ isSecure: Bool) -> some View {
        var view = self
        view.isSecure = isSecure
        return view
    }

    func clearsOnBeginEditing(_ shouldClear: Bool) -> some View {
        var view = self
        view.clearsOnBeginEditing = shouldClear
        return view
    }

    func disabled(_ disabled: Bool) -> some View {
        var view = self
        view.isUserInteractionEnabled = disabled
        return view
    }
}

Both of these types though unfortunately run into the same problems and face the same limiting constraints.

I'm sure that we can all agree that due to limitations in SwiftUI (available types, control, etc.), being able to fluidly make your own types that interface with UIKit is incredibly important.

As of now, it seems to me that there is no wat to do this in a really meaningful way wherein the fact that a view is backed by UIKit is just an implementation detail that does not how one would use it when compared to a native SwiftUI view in any way. Is there any way this can be achieved even in some capacity?

Any help to make this better is appreciated!

Related to this topic, does anyone know how to allow a child view that conforms to UIViewRepresentable to respond to vie modifier extensions called on parent views?

Also, if you have tried to make your own types backed by UIKit to work nicely with SwiftUI I would love to hear how you went about it. Lastly, if you have any idea or guess as to why Apple decided to do this to us, I'd be interested to know.

9 Likes

I agree with you 100% on the importance of making your wrapped UIKit components seamlessly integrate with SwiftUI. And I think you are also correct that Apple may just change everything under the hood with SwiftUI. In fact, they would be crazy not to, but they should:

Provide access the modifiers attached to the view
Provide a way for custom UIViewRepresentables to use SwiftUI classes like Color and Font, even if they deprecate them and provide better replacements.

I've written an extensive MapView and had to write a wrapper for UITableView because List was just not cutting it with large data sets. Ran into similar issues and have no real solution.

Good luck, thanks for writing this, and keep us posted.

Heya, I found this article looking for... exactly this, tbh!
Is there any reason you set the text view to resign first responder so frequently? I found that I can type perhaps 3 characters, but usually just one or two, with your implementation as-posted here.

Also - and this I doubt as its only been three months - have you had any further joy in the SwiftUI -> UIKit conversion for colours and fonts?

I needed this myself and thanks to the suggestions here managed to write a similar solution, albeit with a few differences in the approach.

My 'modifier' functions simply return specifically the type TextView. This allows me to chain the modifiers however it does require that you call all TextView specific modifiers before any others. In practice I think this works fairly nicely, although I agree with the above that I'd love to see better support for custom view modifiers in the future.

Usage

TextView("Placeholder", text: .constant("Sample Text"))
            .foregroundColor(.red)
            .multilineTextAlignment(.center)
            .font(.system(.body, design: .serif))
            .fontWeight(.bold)
            .placeholderFont(Font.system(.body, design: .serif))

Notes

I love that we can finally UIColor <> Color in Xcode 12, but I really wish they'd also included UIFont <> Font. To help with this I have also added a UIFont extension that provides the same API as Font. I think this would be a nice addition to UIKit as well.

Lastly, its a more complete example, with closures for shouldEdit, onEdit and onCommit. Allows modification to almost all properties (I might have missed some?).

If you're interested in using the view you can find it here:

1 Like

Just a quick update, I have now released an official package that contains an updated and supported version of this here: TextView

The Orgnanization also contains many other useful packages and the whole Signed Collection can be added to Xcode 13.

1 Like