I have a multi line text field, and need it to become a first responder when it appears. Also, it needs to resign as a first responder when the onCommit parameter of MultiLineTextField is fired, or one of 2 buttons are tapped.
As it stands, the keyboard dismisses when it should but immediately reappears when it shouldn't.
I know I could just use the new iOS 16 TextField .axis parameter, but I need to stick with iOS 15.5, hence the long drawn out code below.
import SwiftUI
struct MultilineTextField: View {
private var placeholder: String
private var onCommit: (() -> Void)?
#State private var viewHeight: CGFloat = 40 //start with one line
#State private var shouldShowPlaceholder = false
#Binding private var text: String
private var internalText: Binding<String> {
Binding<String>(get: { self.text } ) {
self.text = $0
self.shouldShowPlaceholder = $0.isEmpty
}
}
var body: some View {
UITextViewWrapper(text: self.internalText, calculatedHeight: $viewHeight, onDone: onCommit)
.frame(minHeight: viewHeight, maxHeight: viewHeight)
.background(placeholderView, alignment: .topLeading)
}
var placeholderView: some View {
Group {
if shouldShowPlaceholder {
Text(placeholder).foregroundColor(.gray)
.padding(.leading, 4)
.padding(.top, 8)
}
}
}
init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.onCommit = onCommit
self._text = text
self._shouldShowPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
}
}
private struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
#Binding var text: String
#Binding var calculatedHeight: CGFloat
var onDone: (() -> Void)?
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textField = UITextView()
textField.delegate = context.coordinator
textField.isEditable = true
textField.font = UIFont.preferredFont(forTextStyle: .body)
textField.isSelectable = true
textField.isUserInteractionEnabled = true
textField.autocorrectionType = .no
textField.isScrollEnabled = false
textField.backgroundColor = UIColor.clear
textField.keyboardType = .asciiCapable
textField.textColor = .systemBlue
textField.textAlignment = .center
if nil != onDone {
textField.returnKeyType = .done
}
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textField
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if uiView.text != self.text {
uiView.text = self.text
}
if uiView.window != nil, !uiView.isFirstResponder {
uiView.becomeFirstResponder()
}
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
}
private 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 // call in next render cycle.
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(text: Binding<String>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
self.text = text
self.calculatedHeight = height
self.onDone = onDone
}
func textViewDidChange(_ uiView: UITextView) {
text.wrappedValue = uiView.text
UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if let onDone = self.onDone, text == "\n" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}
}
MultilineTextField Usage:
MultilineTextField("", text: Binding<String>(
get: { userAnswer },
set: {
self.userAnswer = $0.allowedCharacters(string: $0)
self.enableHint()
}), onCommit: {
if self.userAnswer.isEmpty {
nativeForeignPlaceholder = NSMutableAttributedString(string: "Tap here to answer...")
} else {
answerDisabled = true
checkAnswer()
}
})
.overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.systemBlue, lineWidth: overlayLineWidth))
.modifier(TextFieldClearButton(text: $userAnswer))
.placeholder(when: userAnswer.isEmpty) {
TextWithAttributedString(attributedString: nativeForeignPlaceholder ?? NSMutableAttributedString())
}
.font(.system(size: fontSize, weight: .regular, design: .rounded))
.lineLimit(2)
.disabled(answerDisabled)
.onAppear {
// key part: delay setting isFocused until after some-internal-iOS setup
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
answerIsFocused = true
}
}
.onTapGesture(perform: tapOnAnswerField)
.frame(dynamicWidth: 675, dynamicHeight: 100, alignment: .center)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY + geometry.size.height / 8.5)
func tapOnAnswerField() {
answerIsFocused = true
}
Related
Would like to have the toolbar show all the time.
Any help is greatly appreciated as this is a real drag for the user experience.
I've added a toolbar to the keyboard for the TextView as shown below.
However the toolbar only shows after the app has run once. Meaning the toolbar does not show the first time the app is run. the app works every time after the initial load.
This is on IOS 14.3, Xcode 12.3, Swift 5, iMessage extension app. Fails on simulator or real device.
struct CustomTextEditor: UIViewRepresentable {
#Binding var text: String
private var returnType: UIReturnKeyType
private var keyType: UIKeyboardType
private var displayDoneBar: Bool
private var commitHandler: (()->Void)?
init(text: Binding<String>,
returnType: UIReturnKeyType = .done,
keyboardType: UIKeyboardType,
displayDoneBar: Bool,
onCommit: (()->Void)?) {
self._text = text
self.returnType = returnType
self.keyType = keyboardType
self.displayDoneBar = displayDoneBar
self.commitHandler = onCommit
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.keyboardType = keyType
textView.returnKeyType = returnType
textView.backgroundColor = .clear
textView.font = UIFont.systemFont(ofSize: 20, weight: .regular)
textView.isEditable = true
textView.delegate = context.coordinator
if self.displayDoneBar {
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace,
target: self,
action: nil)
let doneButton = UIBarButtonItem(title: "Close Keyboard",
style: .done,
target: self,
action: #selector(textView.doneButtonPressed(button:)))
let toolBar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 300, height: 50))
toolBar.items = [flexibleSpace, doneButton, flexibleSpace]
toolBar.setItems([flexibleSpace, doneButton, flexibleSpace], animated: true)
toolBar.sizeToFit()
textView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
textView.translatesAutoresizingMaskIntoConstraints = true
textView.inputAccessoryView = toolBar
}
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
textView.text = text
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: CustomTextEditor
init(_ textView: CustomTextEditor) {
self.parent = textView
}
func textViewDidChange(_ textView: UITextView) {
self.parent.$text.wrappedValue = textView.text
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.$text.wrappedValue = textView.text
parent.commitHandler?()
}
}
}
extension UITextView {
#objc func doneButtonPressed(button:UIBarButtonItem) -> Void {
self.resignFirstResponder()
}
}
This is how it's called...
import SwiftUI
final class ContentViewHostController: UIHostingController<ContentView> {
weak var myWindow: UIWindow?
init() {
super.init(rootView: ContentView())
}
required init?(coder: NSCoder) {
super.init(coder: coder, rootView: ContentView())
}
}
let kTextColor = Color(hex: "3E484F")
let kOverlayRadius: CGFloat = 10
let kOverlayWidth: CGFloat = 2
let kOverlayColor = kTextColor
struct ContentView: View {
#State var text = ""
var body: some View {
VStack {
Spacer()
CustomTextEditor(text: $text, returnType: .default, keyboardType: .default, displayDoneBar: true, onCommit: nil)
.foregroundColor(kTextColor)
.overlay(
RoundedRectangle(cornerRadius: kOverlayRadius)
.stroke(kOverlayColor, lineWidth: kOverlayWidth)
)
.frame(width: 200, height: 100, alignment: .center)
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
From MessagesViewController...
override func willBecomeActive(with conversation: MSConversation) {
let childViewCtrl = ContentViewHostController()
childViewCtrl.view.layoutIfNeeded() // avoids snapshot warning?
if let window = self.view.window {
childViewCtrl.myWindow = window
window.rootViewController = childViewCtrl
}
}
I try to make custom textfield so I give it #Binding to response to the text value as showing below, The problem is when I try to detect the change of text its just response in the "Preview", But when run the app on the "Simulator" it doesn't response, I tried many different ways to solve this problem but nothing is work.
import SwiftUI
struct MyTextField: UIViewRepresentable {
typealias UIViewType = UITextField
#Binding var becomeFirstResponder: Bool
#Binding var text: String
var placeholder = ""
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.translatesAutoresizingMaskIntoConstraints = false
textField.widthAnchor.constraint(equalToConstant: 320).isActive = true
textField.textColor = UIColor.systemBlue
textField.font = UIFont.boldSystemFont(ofSize: 22)
textField.textAlignment = .left
textField.keyboardType = .default
textField.minimumFontSize = 13
textField.adjustsFontSizeToFitWidth = true
textField.text = self._text.wrappedValue
textField.placeholder = self.placeholder
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
if self.becomeFirstResponder {
DispatchQueue.main.async {
textField.becomeFirstResponder()
self.becomeFirstResponder = false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: MyTextField
init(parent: MyTextField) {
self.parent = parent
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let currentText = textField.text ?? ""
guard let stringRange = Range(range, in: currentText) else {
return false
}
let updateText = currentText.replacingCharacters(in: stringRange, with: string)
return updateText.count < 20
}
}
}
struct TextFieldFirstResponder: View {
#State private var becomeFirstResponder = false
#State private var text = "LLL"
private var placeholder = "Untitled"
var body: some View {
VStack {
ZStack(alignment: .trailing) {
MyTextField(becomeFirstResponder: self.$becomeFirstResponder, text: self.$text, placeholder: self.placeholder)
.frame(width: 343, height: 56, alignment: .leading)
.padding(EdgeInsets(top: 27, leading: 13, bottom: 0, trailing: 0))
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground))
.frame(width: 342, height: 56, alignment: .center)
)
.onAppear {
self.becomeFirstResponder = true
}
}
Text("\(self.$text.wrappedValue)") // <------ Do not read the "text"
}
}
}
struct TextFieldFirstResponder_Previews: PreviewProvider {
static var previews: some View {
TextFieldFirstResponder()
}
}
You don't need to use $ here, read property directly
Text(self.text) // <------ Do not use $
and I assume you wanted to update it via binding
let updateText = currentText.replacingCharacters(in: stringRange, with: string)
self.parent.text = updateText // << here !!
return updateText.count < 20
Tested & worked with Xcode 12.1 / iOS 14.1
My SwiftUI View is kinda acting weird since i added a MultilineTextField. When pressing a item on the List, the view kind jumps back and forth and then it jumps automatically to the last text field in the view as seen in this video. This just happened after i added a MultilineTextField at the end.
MultilineTextField definition:
import Foundation
import SwiftUI
import UIKit
fileprivate struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
#Binding var text: String
#Binding var calculatedHeight: CGFloat
var onDone: (() -> Void)?
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textField = UITextView()
textField.delegate = context.coordinator
textField.isEditable = true
textField.font = UIFont.preferredFont(forTextStyle: .body)
textField.isSelectable = true
textField.isUserInteractionEnabled = true
textField.isScrollEnabled = false
textField.backgroundColor = UIColor.clear
if nil != onDone {
textField.returnKeyType = .done
}
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textField
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if uiView.text != self.text {
uiView.text = self.text
}
if uiView.window != nil, !uiView.isFirstResponder {
uiView.becomeFirstResponder()
}
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
}
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
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(text: Binding<String>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
self.text = text
self.calculatedHeight = height
self.onDone = onDone
}
func textViewDidChange(_ uiView: UITextView) {
text.wrappedValue = uiView.text
UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if let onDone = self.onDone, text == "\n" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}
}
struct MultilineTextField: View {
private var placeholder: String
private var onCommit: (() -> Void)?
#Binding private var text: String
private var internalText: Binding<String> {
Binding<String>(get: { self.text } ) {
self.text = $0
self.showingPlaceholder = $0.isEmpty
}
}
#State private var dynamicHeight: CGFloat = 100
#State private var showingPlaceholder = false
init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.onCommit = onCommit
self._text = text
self._showingPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
}
var body: some View {
UITextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.background(placeholderView, alignment: .topLeading)
}
var placeholderView: some View {
Group {
if showingPlaceholder {
Text(placeholder).foregroundColor(.gray)
.padding(.leading, 4)
.padding(.top, 8)
}
}
}
}
#if DEBUG
struct MultilineTextField_Previews: PreviewProvider {
static var test:String = ""//some very very very long description string to be initially wider than screen"
static var testBinding = Binding<String>(get: { test }, set: {
// print("New value: \($0)")
test = $0 } )
static var previews: some View {
VStack(alignment: .leading) {
Text("Description:")
MultilineTextField("Enter some text here", text: testBinding, onCommit: {
print("Final text: \(test)")
})
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black))
Text("Something static here...")
Spacer()
}
.padding()
}
}
#endif
Code:
struct DetailZwei : View {
#State var data : dataTypeZwei
#State var viewModel = GerätEditieren()
#Environment(\.presentationMode) var presentationMode
#State private var showingAlert = false
var body : some View {
NavigationView {
ScrollView {
VStack {
Group {
Section(header: Text("")) {
Text("Seriennummer")
TextField("Seriennummer", text: $data.sn).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Objekt")
TextField("Objekt", text: $data.objekt).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Gerätetyp")
TextField("Gerätetyp", text: $data.typ).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Geräteposition")
TextField("Geräteposition", text: $data.pos).textFieldStyle(RoundedBorderTextFieldStyle())
}
Group {
Text("Installationsdatum")
TextField("Installationsdatum", text: $data.ida).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Leasing oder Gekauft?")
TextField("Leasing oder Gekauft?", text: $data.lg).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ablaufdatum Leasing")
TextField("Ablaufdatum Leasing", text: $data.la).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ablaufdatum Garantie")
TextField("Ablaufdatum Garantie", text: $data.ga).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("")) {
Text("Strasse")
TextField("Strasse", text: $data.str).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Hausnummer")
TextField("Hausnummer", text: $data.nr).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Postleitzahl")
TextField("Postleitzahl", text: $data.plz).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ort")
TextField("Ort", text: $data.ort).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("")) {
Text("Ansprechperson")
TextField("Ansprechperson", text: $data.vp).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Telefonnummer")
TextField("Telefonnummer", text: $data.tel).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("VDS").bold()) {
Text("Eingetragen durch")
TextField("Eingetragen durch", text: $data.ed).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Lieferdatum VDS")
TextField("Lieferdatum VDS", text: $data.ldvds).textFieldStyle(RoundedBorderTextFieldStyle())
}
// This is the Text Field
Section(header: Text("")) {
Text("Zusätzliche Informationen")
MultilineTextField("Zusätzliche Informationen", text: $data.zusatz).overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black))
}
}.padding()
.navigationBarTitle("Gerät bearbeiten", displayMode: .inline)
.navigationBarItems(leading: Button(action: { self.handleCancelTapped() }, label: {
Text("Abbrechen")
}),
trailing: Button(action: { self.handleDoneTapped() }, label: {
Text("Speichern")
})
// .disabled(!viewModel.modified)
)
}.alert(isPresented: $showingAlert) {
Alert(title: Text("Änderungen gespeichert"), message: Text("Die Änderungen vom Gerät \(data.sn) wurden erfolgreich gespeichert!"), dismissButton: .default(Text("Zurück").bold()){
self.handleCancelTapped()
})
}
}
}
}
When you opening the view, your Custom TextField calls firstResponders. Just remove calling firstResponder on load and your view will start at the beginning.
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if uiView.text != self.text {
uiView.text = self.text
}
if uiView.window != nil, !uiView.isFirstResponder {
//uiView.becomeFirstResponder() << Here calling firstResponder
}
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
}
SwiftUI TextFields do not support firstResponder yet, however with Representable and using UIKit it is possible, like in your solution, Grüezi
I consider how to create SwitUI List that has as its row custom UIViews.
I create List:
List {
RowView()
}
RowView is UIViewRepresentable of UIRowView
struct RowView : UIViewRepresentable {
func makeUIView() -> UIRowView { ... }
}
UIRowView is custom view
UIRowView: UIView { ... }
Currently first rows are displayed but they are usually not layout properly and while scrolling this views disappear instead of being recycled
UPDATE
Example 1
struct NoteView: UIViewRepresentable {
// MARK: - Properties
let note: Note
let date = Date()
func makeUIView(context: Context) -> UINoteView {
let view = UINoteView()
view.note = note
return view
}
func updateUIView(_ uiView: UINoteView, context: Context) {
uiView.note = note
print("View bounds: \(uiView.bounds)")
}
}
var body: some View {
List {
ForEach(Array(notes.enumerated()), id: \.1) { (i, note) in
NoteView(note: note)
.background(Color.green)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}.background(Color.red)
}
Example 2 - Simplified
struct TestView : UIViewRepresentable {
let text : String
func makeUIView(context: Context) -> UILabel {
UILabel()
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.text = text
}
}
var body: some View {
List {
ForEach(0..<30, id: \.self) { i in
TestView(text: "\(i)")
}
}
}
Both seems to work incorrectly, as rows dissapears
I had also issue with views not keeping padding and going outside of the screen if there was more content. Only several first rows (visible initially on screen layouts correctly) other disappears or jump somewhere.
UPDATE 2
Here is Autosizable UINoteView
class UINoteView: UIView {
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupViews()
}
// MARK: - Properties
var note: Note? {
didSet {
textView.attributedText = note?.content?.parsedHtmlAttributedString(textStyle: .html)
noteFooterViewModel.note = note
}
}
// MARK: - Views
lazy var textView: UITextView = {
let textView = UITextView()
textView.translatesAutoresizingMaskIntoConstraints = false
textView.backgroundColor = UIColor.yellow
textView.textContainer.lineBreakMode = .byWordWrapping
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = false
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.isEditable = false
textView.textContainer.maximumNumberOfLines = 0
return textView
}()
lazy var label: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "TEST ROW \(note?.id ?? "")"
return label
}()
lazy var vStack: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
textView,
noteFooter
])
stack.axis = .vertical
stack.alignment = .fill
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
var noteFooterViewModel = NoteFooterViewModel()
var noteFooter: UIView {
let footer = NoteFooter(viewModel: noteFooterViewModel)
let hosting = UIHostingController(rootView: footer)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
return hosting.view
}
private func setupViews() {
self.backgroundColor = UIColor.green
self.addSubview(vStack)
NSLayoutConstraint.activate([
vStack.leadingAnchor.constraint(equalTo: self.leadingAnchor),
vStack.trailingAnchor.constraint(equalTo: self.trailingAnchor),
vStack.topAnchor.constraint(equalTo: self.topAnchor),
vStack.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
}
}
UPDATED ANSWER
try this:
struct TestView : UIViewRepresentable {
let text : String
var label : UILabel = UILabel()
func makeUIView(context: Context) -> UILabel {
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.text = text
}
}
struct ContentView : View {
var body: some View {
List (0..<30, id: \.self) { i in
TestView(text: "\(i)").id(i)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I did like below. You can get the frame size even without UIViewRepresentable by getTextFrame(for note)
var body: some View {
List {
ForEach(Array(notes.enumerated()), id: \.1) { (i, note) in
NoteView(note: note)
// add this
.frame(width: getTextFrame(for: note).width, height: getTextFrame(for: note).height)
.background(Color.green)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}.background(Color.red)
}
func getTextFrame(for text: String, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .body)
]
let attributedText = NSAttributedString(string: text, attributes: attributes)
let width = maxWidth != nil ? min(maxWidth!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let height = maxHeight != nil ? min(maxHeight!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let constraintBox = CGSize(width: width, height: height)
let rect = attributedText.boundingRect(with: constraintBox, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).integral
print(rect.size)
return rect.size
}
struct NoteView: UIViewRepresentable {
let note: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.isEditable = false
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.textContainer.lineBreakMode = .byWordWrapping
textView.text = note
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
}
}
I have created a wrapper that conforms to UIViewControllerRepresentable. I have created a UIViewController which contains a UIScrollView that has paging enabled.
The custom wrapper works as it should.
SwiftyUIScrollView(.horizontal, pagingEnabled: true) {
NavigationLink(destination: Text("This is a test")) {
Text("Navigation Link Test")
}
}
This button appears disabled and greyed out. Clicking it does nothing. However, if the same button is put inside a ScrollView {} wrapper, it works.
What am I missing here. Here is the custom scrollview class code:
enum DirectionX {
case horizontal
case vertical
}
struct SwiftyUIScrollView<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
var axis: DirectionX
var numberOfPages = 0
var pagingEnabled: Bool = false
var pageControlEnabled: Bool = false
var hideScrollIndicators: Bool = false
init(axis: DirectionX, numberOfPages: Int, pagingEnabled: Bool,
pageControlEnabled: Bool, hideScrollIndicators: Bool, #ViewBuilder content:
#escaping () -> Content) {
self.content = content
self.numberOfPages = numberOfPages
self.pagingEnabled = pagingEnabled
self.pageControlEnabled = pageControlEnabled
self.hideScrollIndicators = hideScrollIndicators
self.axis = axis
}
func makeUIViewController(context: Context) -> UIScrollViewController {
let vc = UIScrollViewController()
vc.axis = axis
vc.numberOfPages = numberOfPages
vc.pagingEnabled = pagingEnabled
vc.pageControlEnabled = pageControlEnabled
vc.hideScrollIndicators = hideScrollIndicators
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
class UIScrollViewController: UIViewController, UIScrollViewDelegate {
var axis: DirectionX = .horizontal
var numberOfPages: Int = 0
var pagingEnabled: Bool = false
var pageControlEnabled: Bool = false
var hideScrollIndicators: Bool = false
lazy var scrollView: UIScrollView = {
let view = UIScrollView()
view.delegate = self
view.isPagingEnabled = pagingEnabled
view.showsVerticalScrollIndicator = !hideScrollIndicators
view.showsHorizontalScrollIndicator = !hideScrollIndicators
return view
}()
lazy var pageControl : UIPageControl = {
let pageControl = UIPageControl()
pageControl.numberOfPages = numberOfPages
pageControl.currentPage = 0
pageControl.tintColor = UIColor.white
pageControl.pageIndicatorTintColor = UIColor.gray
pageControl.currentPageIndicatorTintColor = UIColor.white
pageControl.translatesAutoresizingMaskIntoConstraints = false
pageControl.isHidden = !pageControlEnabled
return pageControl
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
self.makefullScreen(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.makefullScreen(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
view.addSubview(pageControl)
pageControl.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50).isActive = true
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
pageControl.heightAnchor.constraint(equalToConstant: 60).isActive = true
pageControl.widthAnchor.constraint(equalToConstant: 200).isActive = true
}
func makefullScreen(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let currentIndexHorizontal = round(scrollView.contentOffset.x / self.view.frame.size.width)
let currentIndexVertical = round(scrollView.contentOffset.y / self.view.frame.size.height)
switch axis {
case .horizontal:
self.pageControl.currentPage = Int(currentIndexHorizontal)
break
case .vertical:
self.pageControl.currentPage = Int(currentIndexVertical)
break
default:
break
}
}
}
UPDATE
This is how I am using the wrapper:
struct TestData {
var id : Int
var text: String
}
struct ContentView: View {
var contentArray: [TestData] = [TestData(id: 0, text: "Test 1"), TestData(id: 1, text: "Test 2"), TestData(id: 2, text: "TEst 3"), TestData(id: 4, text: "Test 4")]
var body: some View {
NavigationView {
GeometryReader { g in
ZStack{
SwiftyUIScrollView(axis: .horizontal, numberOfPages: self.contentArray.count, pagingEnabled: true, pageControlEnabled: true, hideScrollIndicators: true) {
HStack(spacing: 0) {
ForEach(self.contentArray, id: \.id) { item in
TestView(data: item)
.frame(width: g.size.width, height: g.size.height)
}
}
}.frame(width: g.size.width)
}.frame(width: g.size.width, height: g.size.height)
.navigationBarTitle("Test")
}
}
}
}
struct TestView: View {
var data: TestData
var body: some View {
GeometryReader { g in
VStack {
HStack {
Spacer()
}
Text(self.data.text)
Text(self.data.text)
VStack {
NavigationLink(destination: Text("This is a test")) {
Text("Navigation Link Test")
}
}
Button(action: {
print("Do something")
}) {
Text("Button")
}
}.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.yellow)
}
}
}
The "navigation link test" button is greyed out.
I spent some time with your code. I think I understand what the problem is, and found a workaround.
The issue is, I think, that for NavigationLink to be enabled, it needs to be inside a NavigationView. Although yours is, it seems the "connection" is lost with UIHostingController. If you check the UIHostingController.navigationController, you'll see that it is nil.
The only solution I can think of, is having a hidden NavigationLink outside the SwiftyUIScrollView that can be triggered manually (with its isActive parameter). Then inside your SwiftyUIScrollView, you should use a simple button that when tapped, changes your model to toggle the NavigationLink's isActive binding. Below is an example that seems to work fine.
Note that NavigationLink's isActive has a small bug at the moment, but it will probably be fixed soon. To learn more about it: https://swiftui-lab.com/bug-navigationlink-isactive/
window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(MyModel()))
import SwiftUI
class MyModel: ObservableObject {
#Published var navigateNow = false
}
struct TestData {
var id : Int
var text: String
}
struct ContentView: View {
#EnvironmentObject var model: MyModel
var contentArray: [TestData] = [TestData(id: 0, text: "Test 1"), TestData(id: 1, text: "Test 2"), TestData(id: 2, text: "TEst 3"), TestData(id: 4, text: "Test 4")]
var body: some View {
NavigationView {
GeometryReader { g in
ZStack{
NavigationLink(destination: Text("Destination View"), isActive: self.$model.navigateNow) { EmptyView() }
SwiftyUIScrollView(axis: .horizontal, numberOfPages: self.contentArray.count, pagingEnabled: true, pageControlEnabled: true, hideScrollIndicators: true) {
HStack(spacing: 0) {
ForEach(self.contentArray, id: \.id) { item in
TestView(data: item)
.frame(width: g.size.width, height: g.size.height)
}
}
}.frame(width: g.size.width)
}.frame(width: g.size.width, height: g.size.height)
.navigationBarTitle("Test")
}
}
}
}
struct TestView: View {
#EnvironmentObject var model: MyModel
var data: TestData
var body: some View {
GeometryReader { g in
VStack {
HStack {
Spacer()
}
Text(self.data.text)
Text(self.data.text)
VStack {
Button("Pseudo-Navigation Link Test") {
self.model.navigateNow = true
}
}
Button(action: {
print("Do something")
}) {
Text("Button")
}
}.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.yellow)
}
}
}
The other thing is your use of AnyView. It comes with a heavy performance price. It is recommended you only use AnyView with leaf views (not your case). So I did managed to refactor your code to eliminate the AnyView. See below, hope it helps.
import SwiftUI
enum DirectionX {
case horizontal
case vertical
}
struct SwiftyUIScrollView<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
var axis: DirectionX
var numberOfPages = 0
var pagingEnabled: Bool = false
var pageControlEnabled: Bool = false
var hideScrollIndicators: Bool = false
init(axis: DirectionX, numberOfPages: Int,
pagingEnabled: Bool,
pageControlEnabled: Bool,
hideScrollIndicators: Bool,
#ViewBuilder content: #escaping () -> Content) {
self.content = content
self.numberOfPages = numberOfPages
self.pagingEnabled = pagingEnabled
self.pageControlEnabled = pageControlEnabled
self.hideScrollIndicators = hideScrollIndicators
self.axis = axis
}
func makeUIViewController(context: Context) -> UIScrollViewController<Content> {
let vc = UIScrollViewController(rootView: self.content())
vc.axis = axis
vc.numberOfPages = numberOfPages
vc.pagingEnabled = pagingEnabled
vc.pageControlEnabled = pageControlEnabled
vc.hideScrollIndicators = hideScrollIndicators
return vc
}
func updateUIViewController(_ viewController: UIScrollViewController<Content>, context: Context) {
viewController.hostingController.rootView = self.content()
}
}
class UIScrollViewController<Content: View>: UIViewController, UIScrollViewDelegate {
var axis: DirectionX = .horizontal
var numberOfPages: Int = 0
var pagingEnabled: Bool = false
var pageControlEnabled: Bool = false
var hideScrollIndicators: Bool = false
lazy var scrollView: UIScrollView = {
let view = UIScrollView()
view.delegate = self
view.isPagingEnabled = pagingEnabled
view.showsVerticalScrollIndicator = !hideScrollIndicators
view.showsHorizontalScrollIndicator = !hideScrollIndicators
return view
}()
lazy var pageControl : UIPageControl = {
let pageControl = UIPageControl()
pageControl.numberOfPages = numberOfPages
pageControl.currentPage = 0
pageControl.tintColor = UIColor.white
pageControl.pageIndicatorTintColor = UIColor.gray
pageControl.currentPageIndicatorTintColor = UIColor.white
pageControl.translatesAutoresizingMaskIntoConstraints = false
pageControl.isHidden = !pageControlEnabled
return pageControl
}()
init(rootView: Content) {
self.hostingController = UIHostingController<Content>(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var hostingController: UIHostingController<Content>! = nil
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
self.makefullScreen(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.makefullScreen(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
view.addSubview(pageControl)
pageControl.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50).isActive = true
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
pageControl.heightAnchor.constraint(equalToConstant: 60).isActive = true
pageControl.widthAnchor.constraint(equalToConstant: 200).isActive = true
}
func makefullScreen(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let currentIndexHorizontal = round(scrollView.contentOffset.x / self.view.frame.size.width)
let currentIndexVertical = round(scrollView.contentOffset.y / self.view.frame.size.height)
switch axis {
case .horizontal:
self.pageControl.currentPage = Int(currentIndexHorizontal)
break
case .vertical:
self.pageControl.currentPage = Int(currentIndexVertical)
break
default:
break
}
}
}
The above solution works if we are not required to navigate to different screens from the content of scroll view. However, if we need a navigation link onto the scroll content instead of the scroll view itself, then the below code would work perfectly.
I was into a similar problem. I have figured out that the problem is with the UIViewControllerRepresentable. Instead use UIViewRepresentable, although I am not sure what the issue is. I was able to get the navigationlink work using the below code.
struct SwiftyUIScrollView<Content>: UIViewRepresentable where Content: View {
typealias UIViewType = Scroll
var content: () -> Content
var pagingEnabled: Bool = false
var hideScrollIndicators: Bool = false
#Binding var shouldUpdate: Bool
#Binding var currentIndex: Int
var onScrollIndexChanged: ((_ index: Int) -> Void)
public init(pagingEnabled: Bool,
hideScrollIndicators: Bool,
currentIndex: Binding<Int>,
shouldUpdate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content, onScrollIndexChanged: #escaping ((_ index: Int) -> Void)) {
self.content = content
self.pagingEnabled = pagingEnabled
self._currentIndex = currentIndex
self._shouldUpdate = shouldUpdate
self.hideScrollIndicators = hideScrollIndicators
self.onScrollIndexChanged = onScrollIndexChanged
}
func makeUIView(context: UIViewRepresentableContext<SwiftyUIScrollView>) -> UIViewType {
let hosting = UIHostingController(rootView: content())
let view = Scroll(hideScrollIndicators: hideScrollIndicators, isPagingEnabled: pagingEnabled)
view.scrollDelegate = context.coordinator
view.alwaysBounceHorizontal = true
view.addSubview(hosting.view)
makefullScreen(of: hosting.view, to: view)
return view
}
class Coordinator: NSObject, ScrollViewDelegate {
func didScrollToIndex(_ index: Int) {
self.parent.onScrollIndexChanged(index)
}
var parent: SwiftyUIScrollView
init(_ parent: SwiftyUIScrollView) {
self.parent = parent
}
}
func makeCoordinator() -> SwiftyUIScrollView<Content>.Coordinator {
Coordinator(self)
}
func updateUIView(_ uiView: Scroll, context: UIViewRepresentableContext<SwiftyUIScrollView<Content>>) {
if shouldUpdate {
uiView.scrollToIndex(index: currentIndex)
}
}
func makefullScreen(of childView: UIView, to parentView: UIView) {
childView.translatesAutoresizingMaskIntoConstraints = false
childView.leftAnchor.constraint(equalTo: parentView.leftAnchor).isActive = true
childView.rightAnchor.constraint(equalTo: parentView.rightAnchor).isActive = true
childView.topAnchor.constraint(equalTo: parentView.topAnchor).isActive = true
childView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor).isActive = true
}
}
Then create a new class to handle the delegates of a scrollview. You can include the below code into the UIViewRepresentable as well. But I prefer keeping it separated for a clean code.
class Scroll: UIScrollView, UIScrollViewDelegate {
var hideScrollIndicators: Bool = false
var scrollDelegate: ScrollViewDelegate?
var tileWidth = 270
var tileMargin = 20
init(hideScrollIndicators: Bool, isPagingEnabled: Bool) {
super.init(frame: CGRect.zero)
showsVerticalScrollIndicator = !hideScrollIndicators
showsHorizontalScrollIndicator = !hideScrollIndicators
delegate = self
self.isPagingEnabled = isPagingEnabled
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let currentIndex = scrollView.contentOffset.x / CGFloat(tileWidth+tileMargin)
scrollDelegate?.didScrollToIndex(Int(currentIndex))
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentIndex = scrollView.contentOffset.x / CGFloat(tileWidth+tileMargin)
scrollDelegate?.didScrollToIndex(Int(currentIndex))
}
func scrollToIndex(index: Int) {
let newOffSet = CGFloat(tileWidth+tileMargin) * CGFloat(index)
contentOffset = CGPoint(x: newOffSet, y: contentOffset.y)
}
}
Now to implement the scrollView use the below code.
#State private var activePageIndex: Int = 0
#State private var shouldUpdateScroll: Bool = false
SwiftyUIScrollView(pagingEnabled: false, hideScrollIndicators: true, currentIndex: $activePageIndex, shouldUpdate: $shouldUpdateScroll, content: {
HStack(spacing: 20) {
ForEach(self.data, id: \.id) { data in
NavigationLink(destination: self.getTheNextView(data: data)) {
self.cardView(data: data)
}
}
}
.padding(.horizontal, 30.0)
}, onScrollIndexChanged: { (newIndex) in
shouldUpdateScroll = false
activePageIndex = index
// Your own required handling
})
func getTheNextView(data: Any) -> AnyView {
// Return the required destination View
}
I had this same issue and tried lots of different solutions. The navigation link had been working and stopped. putting the view inside a navigation view worked.
In the example, masterview() contains the navigation links that did not work and now do.
struct ContentView: View {
var body: some View {
NavigationView {
MasterView()
//SettingsView()
//DetailView()
//newviewcontroller()
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}