Editable Text() [ Text() that can be TextField() on double tap] - swiftui

Some time ago I had a problem with making UI logic of view that can be Label and EditBox both:
I mean Text() -> doubleClick action -> display TextEdit() -> fill it with new text -> Enter key press -> text changes saved and displayed in Text()
solution is in answer below

import SwiftUI
#available(macOS 10.15, *)
public struct EditableLabel: View {
#Binding var text: String
#State private var newValue: String = ""
#State var editProcessGoing = false { didSet{ newValue = text } }
let onEditEnd: () -> Void
public init(_ txt: Binding<String>, onEditEnd: #escaping () -> Void) {
_text = txt
self.onEditEnd = onEditEnd
}
#ViewBuilder
public var body: some View {
ZStack {
// Text variation of View
Text(text)
.opacity(editProcessGoing ? 0 : 1)
// TextField for edit mode of View
TextField("", text: $newValue,
onEditingChanged: { _ in },
onCommit: { text = newValue; editProcessGoing = false; onEditEnd() } )
.opacity(editProcessGoing ? 1 : 0)
}
// Enable EditMode on double tap
.onTapGesture(count: 2, perform: { editProcessGoing = true } )
// Exit from EditMode on Esc key press
.onExitCommand(perform: { editProcessGoing = false; newValue = text })
}
}
sample of usage:
EditableLabel($name, onEditEnd: { print("New name is *\(name)* ") } )

Related

Property Wrapper doesn't affect TextField

I wrote MaxCount propertyWrapper to limit String count in TextField. However, while Text view shows trimmed String, TextField shows full String.
I can achieve expected behavior via below ViewModifier, but this doesn't seem a good practice to me, I would like to achieve that behaviour via #propertyWrapper.
TextField("Type here...", text: $text)
.onChange(of: text) { newText in
// Check if newText has more characters than maxCount, if so trim it.
guard maxCount < newText.count else { text = newText; return }
text = String(newText.prefix(maxCount))
}
MaxCount.swift
#propertyWrapper struct MaxCount<T: RangeReplaceableCollection>: DynamicProperty {
// MARK: Properties
private var count: Int = 0
#State private var value: T = .init()
var wrappedValue: T {
get { value }
nonmutating set {
value = limitValue(newValue, count: count)
}
}
var projectedValue: Binding<T> {
Binding(
get: { value },
set: { wrappedValue = $0 }
)
}
// MARK: Initilizations
init(wrappedValue: T, _ count: Int) {
self.count = count
self._value = State(wrappedValue: limitValue(wrappedValue, count: count))
}
// MARK: Functions
private func limitValue(_ value: T, count: Int) -> T {
guard value.count > count else { return value }
let lastIndex = value.index(value.startIndex, offsetBy: count - 1)
let firstIndex = value.startIndex
return T(value[firstIndex...lastIndex])
}
}
ContentView.swift
struct ContentView: View {
#MaxCount(5) private var text = "This is a test text"
var body: some View {
VStack {
Text(text)
TextField("Type here...", text: $text)
}
}
}
I ended up building a new TextField as below.
Drawback: It doesn't support initialization with formatters which exists in TextField
struct FilteredTextField<Label: View>: View {
// MARK: Properties
private let label: Label
private var bindingText: Binding<String>
private let prompt: Text?
private let filter: (String) -> Bool
#State private var stateText: String
#State private var lastValidText: String = ""
// MARK: Initializations
init(text: Binding<String>, prompt: Text? = nil, label: () -> Label, filter: ((String) -> Bool)? = nil) {
self.label = label()
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ titleKey: LocalizedStringKey, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(titleKey)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ title: String, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(title)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
// MARK: View
var body: some View {
TextField(text: $stateText, prompt: prompt, label: { label })
.onChange(of: stateText) { newValue in
guard newValue != bindingText.wrappedValue else { return }
guard filter(newValue) else { stateText = lastValidText; return }
bindingText.wrappedValue = newValue
}
.onChange(of: bindingText.wrappedValue) { newValue in
if filter(newValue) { lastValidText = newValue }
stateText = newValue
}
}
}
Usage
struct ContentView: View {
#State var test: String = ""
var body: some View {
VStack {
HStack {
Text("Default TextField")
TextField(text: $test, label: { Text("Type here...") })
}
HStack {
Text("FilteredTextField")
FilteredTextField(text: $test, label: { Text("Type here...") }) { inputString in inputString.count <= 5 }
}
}
}
}

SwiftUI: stay on same TextField after on commit?

is it possible in SwiftUI to keep the typing cursor on the same Textfield even after the user taps on Return key on keyboard ?
Here is my code:
struct RowView: View {
#Binding var checklistItem: ChecklistItem
#ObservedObject var checklist = Checklist()
#ObservedObject var viewModel: ChecklistViewModel
var body: some View {
HStack {
Button {
self.checklistItem.isChecked.toggle()
self.viewModel.updateChecklist(checklistItem)
} label: {
Circle()
.strokeBorder(checklistItem.isChecked ? checklistSelected : contentPrimary, lineWidth: checklistItem.isChecked ? 6 : 2)
.foregroundColor(backgroundSecondary)
.clipShape(Circle())
.frame(width: 16, height: 16)
}.buttonStyle(BorderlessButtonStyle())
// swiftlint:disable trailing_closure
TextField(
"Add...",
text: $checklistItem.name,
onCommit: {
do {
if !checklistItem.name.isEmpty {
self.viewModel.updateChecklist(checklistItem)
self.checklistItem.name = checklistItem.name
}
}
}
)
// swiftlint:enable trailing_closure
.foregroundColor(checklistItem.isChecked ? contentTertiary : contentPrimary)
Spacer()
}
}
}
So after the user taps on return key on keyboard, TextField() onCommit should be activated normally but the cursor stays in that same textfield so the user can keep typing in new elements.
iOS 15+
You can use #FocusState and, on commit, immediately set the TextField to have focus again.
Example:
struct ContentView: View {
#State private var text = "Hello world!"
#FocusState private var isFieldFocused: Bool
var body: some View {
Form {
TextField("Field", text: $text, onCommit: {
isFieldFocused = true
print("onCommit")
})
.focused($isFieldFocused)
}
}
}
Result:
I was able to achieve this in iOS 14 by creating a custom TextField class:
struct AlwaysActiveTextField: UIViewRepresentable {
let placeholder: String
#Binding var text: String
var focusable: Binding<[Bool]>?
var returnKeyType: UIReturnKeyType = .next
var autocapitalizationType: UITextAutocapitalizationType = .none
var keyboardType: UIKeyboardType = .default
var isSecureTextEntry: Bool
var tag: Int
var onCommit: () -> Void
func makeUIView(context: Context) -> UITextField {
let activeTextField = UITextField(frame: .zero)
activeTextField.delegate = context.coordinator
activeTextField.placeholder = placeholder
activeTextField.font = .systemFont(ofSize: 14)
activeTextField.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [NSAttributedString.Key.foregroundColor: UIColor(contentSecondary)]
)
activeTextField.returnKeyType = returnKeyType
activeTextField.autocapitalizationType = autocapitalizationType
activeTextField.keyboardType = keyboardType
activeTextField.isSecureTextEntry = isSecureTextEntry
activeTextField.textAlignment = .left
activeTextField.tag = tag
// toolbar
if keyboardType == .numberPad { // keyboard does not have next so add next button in the toolbar
var items = [UIBarButtonItem]()
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let toolbar: UIToolbar = UIToolbar()
toolbar.sizeToFit()
let nextButton = UIBarButtonItem(title: "Next", style: .plain, target: context.coordinator, action: #selector(Coordinator.showNextTextField))
items.append(contentsOf: [spacer, nextButton])
toolbar.setItems(items, animated: false)
activeTextField.inputAccessoryView = toolbar
}
// Editin listener
activeTextField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)
return activeTextField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
if let focusable = focusable?.wrappedValue {
if focusable[uiView.tag] { // set focused
uiView.becomeFirstResponder()
} else { // remove keyboard
uiView.resignFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
let activeTextField: AlwaysActiveTextField
var hasEndedViaReturn = false
weak var textField: UITextField?
init(_ activeTextField: AlwaysActiveTextField) {
self.activeTextField = activeTextField
}
func textFieldDidBeginEditing(_ textField: UITextField) {
self.textField = textField
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
var focusable: [Bool] = Array(repeating: false, count: textFieldCount) // remove focus from all text field
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
}
// work around for number pad
#objc
func showNextTextField() {
if let textField = self.textField {
_ = textFieldShouldReturn(textField)
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
hasEndedViaReturn = true
guard var focusable = activeTextField.focusable?.wrappedValue else {
textField.resignFirstResponder()
return true
}
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
activeTextField.onCommit()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if !hasEndedViaReturn {// user dismisses keyboard
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
// reset all text field, so that makeUIView cannot trigger keyboard
activeTextField.focusable?.wrappedValue = Array(repeating: false, count: textFieldCount)
} else {
hasEndedViaReturn = false
}
}
#objc
func textFieldDidChange(_ textField: UITextField) {
activeTextField.text = textField.text ?? ""
}
}
}
and use in in the SwiftUI view by adding this #State variable:
#State var fieldFocus: [Bool] = [false]
and add the Textfield code it self anywhere waiting the view body:
AlwaysActiveTextField(
placeholder: "Add...",
text: $newItemName,
focusable: $fieldFocus,
returnKeyType: .next,
isSecureTextEntry: false,
tag: 0,
onCommit: {
print("any action you want on commit")
}
)

How to force re-create view in SwiftUI?

I made a view which fetches and shows a list of data. There is a context menu in toolbar where user can change data categories. This context menu lives outside of the list.
What I want to do is when user selects a category, the list should refetch data from backend and redraw entire of the view.
I made a BaseListView which can be reused in various screens in my app, and since the loadData is inside the BaseListView, I don't know how to invoke it to reload data.
Did I do this with good approaching? Is there any way to force SwiftUI recreates entire of view so that the BaseListView loads data & renders subviews as first time it's created?
struct ProductListView: View {
var body: some View {
BaseListView(
rowView: { ProductRowView(product: $0, searchText: $1)},
destView: { ProductDetailsView(product: $0) },
dataProvider: {(pageIndex, searchText, complete) in
return fetchProducts(pageIndex, searchText, complete)
})
.hideKeyboardOnDrag()
.toolbar {
ProductCategories()
}
.onReceive(self.userSettings.$selectedCategory) { category in
//TODO: Here I need to reload data & recreate entire of view.
}
.navigationTitle("Products")
}
}
extension ProductListView{
private func fetchProducts(_ pageIndex: Int,_ searchText: String, _ complete: #escaping ([Product], Bool) -> Void) -> AnyCancellable {
let accountId = Defaults.selectedAccountId ?? ""
let pageSize = 20
let query = AllProductsQuery(id: accountId,
pageIndex: pageIndex,
pageSize: pageSize,
search: searchText)
return Network.shared.client.fetchPublisher(query: query)
.sink{ completion in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("Success")
}
} receiveValue: { response in
if let data = response.data?.getAllProducts{
let canLoadMore = (data.count ?? 0) > pageSize * pageIndex
let rows = data.rows
complete(rows, canLoadMore)
}
}
}
}
ProductCategory is a separated view:
struct ProductCategories: View {
#EnvironmentObject var userSettings: UserSettings
var categories = ["F&B", "Beauty", "Auto"]
var body: some View{
Menu {
ForEach(categories,id: \.self){ item in
Button(item, action: {
userSettings.selectedCategory = item
Defaults.selectedCategory = item
})
}
}
label: {
Text(self.userSettings.selectedCategory ?? "All")
.regularText()
.autocapitalization(.words)
.frame(maxWidth: .infinity)
}.onAppear {
userSettings.selectedCategory = Defaults.selectedCategory
}
}
}
Since my app has various list-view with same behaviours (Pagination, search, ...), I make a BaseListView like this:
struct BaseListView<RowData: StringComparable & Identifiable, RowView: View, Target: View>: View {
enum ListState {
case loading
case loadingMore
case loaded
case error(Error)
}
typealias DataCallback = ([RowData],_ canLoadMore: Bool) -> Void
#State var rows: [RowData] = Array()
#State var state: ListState = .loading
#State var searchText: String = ""
#State var pageIndex = 1
#State var canLoadMore = true
#State var cancellableSet = Set<AnyCancellable>()
#ObservedObject var searchBar = SearchBar()
#State var isLoading = false
let rowView: (RowData, String) -> RowView
let destView: (RowData) -> Target
let dataProvider: (_ page: Int,_ search: String, _ complete: #escaping DataCallback) -> AnyCancellable
var searchable: Bool?
var body: some View {
HStack{
content
}
.if(searchable != false){view in
view.add(searchBar)
}
.hideKeyboardOnDrag()
.onAppear(){
print("On appear")
searchBar.$text
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.sink { text in
print("Search bar updated")
self.state = .loading
self.pageIndex = 1
self.searchText = text
self.rows.removeAll()
self.loadData()
}.store(in: &cancellableSet)
}
}
private var content: some View{
switch state {
case .loading:
return Spinner(isAnimating: true, style: .large).eraseToAnyView()
case .error(let error):
print(error)
return Text("Unable to load data").eraseToAnyView()
case .loaded, .loadingMore:
return
ScrollView{
list(of: rows)
}
.eraseToAnyView()
}
}
private func list(of data: [RowData])-> some View{
LazyVStack{
let filteredData = rows.filter({
searchText.isEmpty || $0.contains(string: searchText)
})
ForEach(filteredData){ dataItem in
VStack{
//Row content:
if let target = destView(dataItem), !(target is EmptyView){
NavigationLink(destination: target){
row(dataItem)
}
}else{
row(dataItem)
}
//LoadingMore indicator
if case ListState.loadingMore = self.state{
if self.rows.isLastItem(dataItem){
Seperator(color: .gray)
LoadingView(withText: "Loading...")
}
}
}
}
}
}
private func row(_ dataItem: RowData) -> some View{
rowView(dataItem, searchText).onAppear(){
//Check if need to load next page of data
if rows.isLastItem(dataItem) && canLoadMore && !isLoading{
isLoading = true
state = .loadingMore
pageIndex += 1
print("Load page \(pageIndex)")
loadData()
}
}.padding(.horizontal)
}
private func loadData(){
dataProvider(pageIndex, searchText){ newData, canLoadMore in
self.state = .loaded
rows.append(contentsOf: newData)
self.canLoadMore = canLoadMore
isLoading = false
}
.store(in: &cancellableSet)
}
}
In your BaseListView you should have an onChange modifier that catches changes to userSettings.$selectedCategory and calls loadData there.
If you don't have access to userSettings in BaseListView, pass it in as a Binding or #EnvironmentObject.

SwiftUI: Textfield shake animation when input is not valid

I want to create a shake animation when the User presses the "save"-button and the input is not valid. My first approach is this (to simplify I removed the modifiers and not for this case relevant attributes):
View:
struct CreateDeckView: View {
#StateObject var viewModel = CreateDeckViewModel()
HStack {
TextField("Enter title", text: $viewModel.title)
.offset(x: viewModel.isValid ? 0 : 10) //
.animation(Animation.default.repeatCount(5).speed(4)) // shake animation
Button(action: {
viewModel.buttonPressed = true
viewModel.saveDeck(){
self.presentationMode.wrappedValue.dismiss()
}
}, label: {
Text("Save")
})
}
}
ViewModel:
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var buttonPressed = false
var validTitle: Bool {
buttonPressed && !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){ ... }
}
But this solution doesn't really work. For the first time when I press the button nothing happens. After that when I change the textfield it starts to shake.
using GeometryEffect,
struct ContentView: View {
#StateObject var viewModel = CreateDeckViewModel()
var body: some View {
HStack {
TextField("Enter title", text: $viewModel.title)
.modifier(ShakeEffect(shakes: viewModel.shouldShake ? 2 : 0)) //<- here
.animation(Animation.default.repeatCount(6).speed(3))
Button(action: {
viewModel.saveDeck(){
...
}
}, label: {
Text("Save")
})
}
}
}
//here
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
init(shakes: Int) {
position = CGFloat(shakes)
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var shouldShake = false
var validTitle: Bool {
!(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){
if !validTitle {
shouldShake.toggle() //<- here (you can use PassThrough subject insteadof toggling.)
}
}
}

Custom UITextField wrapped in UIViewRepresentable interfering with ObservableObject in NavigationView

Context
I have created a UIViewRepresentable to wrap a UITextField so that:
it can be set it to become the first responder when the view loads.
the next textfield can be set to become the first responder when enter is pressed
Problem
When used inside a NavigationView, unless the keyboard is dismissed from previous views, the view doesn't observe the value in their ObservedObject.
Question
Why is this happening? What can I do to fix this behaviour?
Screenshots
Keyboard from root view not dismissed:
Keyboard from root view dismissed:
Code
Here is the said UIViewRepresentable
struct SimplifiedFocusableTextField: UIViewRepresentable {
#Binding var text: String
private var isResponder: Binding<Bool>?
private var placeholder: String
private var tag: Int
public init(
_ placeholder: String = "",
text: Binding<String>,
isResponder: Binding<Bool>? = nil,
tag: Int = 0
) {
self._text = text
self.placeholder = placeholder
self.isResponder = isResponder
self.tag = tag
}
func makeUIView(context: UIViewRepresentableContext<SimplifiedFocusableTextField>) -> UITextField {
// create textfield
let textField = UITextField()
// set delegate
textField.delegate = context.coordinator
// configure textfield
textField.placeholder = placeholder
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.tag = self.tag
// return
return textField
}
func makeCoordinator() -> SimplifiedFocusableTextField.Coordinator {
return Coordinator(text: $text, isResponder: self.isResponder)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<SimplifiedFocusableTextField>) {
// update text
uiView.text = text
// set first responder ONCE
if self.isResponder?.wrappedValue == true && !uiView.isFirstResponder && !context.coordinator.didBecomeFirstResponder{
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
private var isResponder: Binding<Bool>?
var didBecomeFirstResponder = false
init(text: Binding<String>, isResponder: Binding<Bool>?) {
_text = text
self.isResponder = isResponder
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = true
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = false
}
}
}
}
And to reproduce, here is the contentView:
struct ContentView: View {
var body: some View {
return NavigationView { FieldView(tag: 0) }
}
}
and here's the view with the field and its view model
struct FieldView: View {
#ObservedObject private var viewModel = FieldViewModel()
#State private var focus = false
var tag: Int
var body: some View {
return VStack {
// listen to viewModel's value
Text(viewModel.value)
// text field
SimplifiedFocusableTextField("placeholder", text: self.$viewModel.value, isResponder: $focus, tag: self.tag)
// push to stack
NavigationLink(destination: FieldView(tag: self.tag + 1)) {
Text("Continue")
}
// dummy for tapping to dismiss keyboard
Color.green
}
.onAppear {
self.focus = true
}.dismissKeyboardOnTap()
}
}
public extension View {
func dismissKeyboardOnTap() -> some View {
modifier(DismissKeyboardOnTap())
}
}
public struct DismissKeyboardOnTap: ViewModifier {
public func body(content: Content) -> some View {
return content.gesture(tapGesture)
}
private var tapGesture: some Gesture {
TapGesture().onEnded(endEditing)
}
private func endEditing() {
UIApplication.shared.connectedScenes
.filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene}
.compactMap({$0})
.first?.windows
.filter {$0.isKeyWindow}
.first?.endEditing(true)
}
}
class FieldViewModel: ObservableObject {
var subscriptions = Set<AnyCancellable>()
// diplays
#Published var value = ""
}
It looks like SwiftUI rendering engine again over-optimized...
Here is fixed part - just make destination unique forcefully using .id. Tested with Xcode 11.4 / iOS 13.4
NavigationLink(destination: FieldView(tag: self.tag + 1).id(UUID())) {
Text("Continue")
}