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 Color
→ UIColor
, but to Font
→ UIFont
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.