SwiftUI #FocusState - how to give it initial value - ios

I am excited to see the TextField enhancement: focused(...): https://developer.apple.com/documentation/swiftui/view/focused(_:)
I want to use it to show a very simple SwitfUI view that contains only one TextField that has the focus with keyboard open immediately. Not able to get it work:
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
.onAppear {
isFocused = true
}
// ...
Anything wrong? I have trouble to give it default value.

I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
The theory I have behind the bug is that at the time of onAppear the TextField is not ready to become first responder, so isFocused = true and iOS calls becomeFirstResponder behind the scenes, but it fails (ex. the view hierarchy is not yet done setting up).
struct MyView: View {
#State var text: String
#FocusState private var isFocused: Bool
var body: some View {
Form {
TextEditor(text: $text)
.focused($isFocused)
.onChange(of: isFocused) { isFocused in
// this will get called after the delay
}
.onAppear {
// key part: delay setting isFocused until after some-internal-iOS setup
DispatchQueue.main.asyncAfter(deadline: .now()+0.5) {
isFocused = true
}
}
}
}
}

I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
It also works without delay.
DispatchQueue.main.async {
isFocused = true
}

//This work in iOS 15.You can try it.
struct ContentView: View {
#FocusState private var isFocused: Bool
#State private var username = "Test"
var body: some View {
VStack {
TextField("Enter your username", text: $username)
.focused($isFocused).onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isFocused = true
}
}
}
}
}

I've had success adding the onAppear to the outermost view (in your case NavigationView):
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
}
}
}
.onAppear {
isFocused = true
}
}
// ...
I’m not certain but perhaps your onAppear attached to the TextField isn’t running. I would suggest adding a print inside of the onAppear to confirm the code is executing.

I faced the same problem and had the idea to solve it by embedding a UIViewController so could use viewDidAppear. Here is a working example:
import SwiftUI
import UIKit
struct FocusTestView : View {
#State var presented = false
var body: some View {
Button("Click Me") {
presented = true
}
.sheet(isPresented: $presented) {
LoginForm()
}
}
}
struct LoginForm : View {
enum Field: Hashable {
case usernameField
case passwordField
}
#State private var username = ""
#State private var password = ""
#FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .usernameField)
SecureField("Password", text: $password)
.focused($focusedField, equals: .passwordField)
Button("Sign In") {
if username.isEmpty {
focusedField = .usernameField
} else if password.isEmpty {
focusedField = .passwordField
} else {
// handleLogin(username, password)
}
}
}
.uiKitOnAppear {
focusedField = .usernameField
// If your form appears multiple times you might want to check other values before setting the focus.
}
}
}
struct UIKitAppear: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIAppearViewController {
let vc = UIAppearViewController()
vc.action = action
return vc
}
func updateUIViewController(_ controller: UIAppearViewController, context: Context) {
}
}
class UIAppearViewController: UIViewController {
var action: () -> Void = {}
override func viewDidLoad() {
view.addSubview(UILabel())
}
override func viewDidAppear(_ animated: Bool) {
// had to delay the action to make it work.
DispatchQueue.main.asyncAfter(deadline:.now()) { [weak self] in
self?.action()
}
}
}
public extension View {
func uiKitOnAppear(_ perform: #escaping () -> Void) -> some View {
self.background(UIKitAppear(action: perform))
}
}
UIKitAppear was taken from this dev forum post, modified with dispatch async to call the action. LoginForm is from the docs on FocusState with the uiKitOnAppear modifier added to set the initial focus state.
It could perhaps be improved by using a first responder method of the VC rather than the didAppear, then perhaps the dispatch async could be avoided.

Related

SwiftUI: How to initialize a new StateObject in a parent view?

I have an app architecture similar to the below (simplified) code. I use a WorkoutManager StateObject which I initialize in the set up view, then pass down to its children via EnvironmentObject. The problem is that upon dismissing the .sheet there isn't any life cycle event which initializes a new WorkoutManager, which I need in order to be able to start new workouts consecutively. How in this example below can I give WorkoutView the ability to reinitialize WorkoutManager so that it is a clean object?
import SwiftUI
import HealthKit
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
//Here I want to initialize a new WorkoutManager to clear out the previous workout's state, how?
} label: {
Text("End Workout")
}
}
}
As mentioned in the comments already, the route you probably want to take is reseting the state within the same WorkoutManager. You wouldn't be able to assign a new object to a #StateObject anyway -- you'll end up with compiler errors because of the View's immutable self.
Secondly, I'd suggest that you probably don't want to rely on the Button in your WorkoutView to do this. For example, if the user dismissed the sheet by swiping, that wouldn't get called. Instead, you could listen for the sheet's state in onChange (another method would be using the onDismiss parameter of sheet):
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
func resetState() {
//do whatever you need to do to reset the state
print("Reset state")
}
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
.onChange(of: showingWorkoutView) { newValue in
if !newValue {
workoutManager.resetState()
}
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
} label: {
Text("End Workout")
}
}
}

Cell in List with LazyVGrid Disappears sometimes

I have this:
VStack {
List {
LazyVGrid(columns: gridItemLayout) {
ForEach(viewModel.objects, id: \.fileGroupUUID) { item in
AlbumItemsScreenCell(object: item, viewModel: viewModel, config: Self.config)
.onTapGesture {
switch viewModel.changeMode {
case .moving, .sharing, .moveAll:
viewModel.toggleItemToChange(item: item)
case .none:
object = item
viewModel.showCellDetails = true
}
}
.onLongPressGesture {
viewModel.restartDownload(fileGroupUUID: item.fileGroupUUID)
}
} // end ForEach
} // end LazyVGrid
}
.listStyle(PlainListStyle())
.refreshable {
viewModel.refresh()
}
.padding(5)
// Mostly this is to animate updates from the menu. E.g., the sorting order.
.animation(.easeInOut)
// Had a problem with return animation for a while: https://stackoverflow.com/questions/65101561
// The solution was to take the NavigationLink out of the scrollview/LazyVGrid above.
if let object = object {
// The `NavigationLink` works here because the `MenuNavBar` contains a `NavigationView`.
NavigationLink(
destination:
ObjectDetailsView(object: object, model: ObjectDetailsModel(object: object)),
isActive:
$viewModel.showCellDetails) {
EmptyView()
}
.frame(width: 0, height: 0)
.disabled(true)
} // end if
} // end VStack
AlbumItemsScreenCell:
struct AlbumItemsScreenCell: View {
#StateObject var object:ServerObjectModel
#StateObject var viewModel:AlbumItemsViewModel
let config: IconConfig
#Environment(\.colorScheme) var colorScheme
var body: some View {
AnyIcon(model: AnyIconModel(object: object), config: config,
emptyUpperRightView: viewModel.changeMode == .none,
upperRightView: {
UpperRightChangeIcon(object: object, viewModel: viewModel)
})
}
}
When a user taps one of the cells, this causes navigation to a details screen. Sometimes when the user returns from that navigation, the cell in the upper left disappears:
https://www.dropbox.com/s/mi6j2ie7h8dcdm0/disappearingCell.mp4?dl=0
My current hypothesis about the issue is that when user actions in that details screen take actions which change viewModel.objects, this causes the disappearing cell problem. I'll be testing this hypothesis shortly.
----- Update, 11/1/21 ------
Well, that hypothesis was wrong. I now understand the structure of the problem more clearly. Still don't have a fix though.
Tapping on one of the AlbumItemsScreenCells navigates to a details screen (I've added to the code above to show that). In the details screen user actions can cause a comment count to get reset, which sends a Notification.
A model in the AlbumItemsScreenCell listens for these notification (for the specific cell) and resets a badge on the cell.
Here is that model:
class AnyIconModel: ObservableObject, CommentCountsObserverDelegate, MediaItemBadgeObserverDelegate, NewItemBadgeObserverDelegate {
#Published var mediaItemBadge: MediaItemBadge?
#Published var unreadCountBadgeText: String?
#Published var newItem: Bool = false
var mediaItemCommentCount:CommentCountsObserver!
let object: ServerObjectModel
var mediaItemBadgeObserver: MediaItemBadgeObserver!
var newItemObserver: NewItemBadgeObserver!
init(object: ServerObjectModel) {
self.object = object
// This is causing https://stackoverflow.com/questions/69783232/cell-in-list-with-lazyvgrid-disappears-sometimes
mediaItemCommentCount = CommentCountsObserver(object: object, delegate: self)
mediaItemBadgeObserver = MediaItemBadgeObserver(object: object, delegate: self)
newItemObserver = NewItemBadgeObserver(object: object, delegate: self)
}
}
The unreadCountBadgeText gets changed (on the main thread) by the observer when the Notification is received.
So, in summary, the badge on the cell gets changed while the screen with the cells is not displayed-- the details screen is displayed.
I had been using the following conditional modifier:
extension View {
public func enabled(_ enabled: Bool) -> some View {
return self.disabled(!enabled)
}
// https://forums.swift.org/t/conditionally-apply-modifier-in-swiftui/32815/16
#ViewBuilder func `if`<T>(_ condition: Bool, transform: (Self) -> T) -> some View where T : View {
if condition {
transform(self)
} else {
self
}
}
}
to display the badge on the AlbumItemsScreenCell.
The original badge looked like this:
extension View {
func upperLeftBadge(_ badgeText: String) -> some View {
return self.modifier(UpperLeftBadge(badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String
init(_ badgeText: String) {
self.badgeText = badgeText
}
func body(content: Content) -> some View {
content
.overlay(
ZStack {
Badge(badgeText)
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
i.e., the usage looked like this in the cell:
.if(condition) {
$0.upperLeftBadge(badgeText)
}
when I changed the modifier to use it with out this .if modifier, and used it directly, the issue went away:
extension View {
func upperLeftBadge(_ badgeText: String?) -> some View {
return self.modifier(UpperLeftBadge(badgeText: badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String?
func body(content: Content) -> some View {
content
.overlay(
ZStack {
if let badgeText = badgeText {
Badge(badgeText)
}
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
It may sounds weird, but in my tvOS app i disable the animations
and cells(Views) stops to disappear
So try to remove .animation(.easeInOut)

Swift UI need to keep both NavigationLink to detail view and Tap gesture recognizer

I am trying a simple app that is a List with items, they lead to detail view. I also have a search bar that opens keyboard, and I need to hide the keyboard when the user taps anywhere outside of the keyboard.
#State private var keyboardOpen: Bool = false
var body: some View {
NavigationView {
Form {
Section {
TextField("Search", text: $cityStore.searchTerm, onCommit: debouncedFetch)
.keyboardType(.namePhonePad)
.disableAutocorrection(true)
.onTapGesture { self.keyboardOpen = true }
.onDisappear { self.keyboardOpen = false }
}
Section {
List {
ForEach(cities) { city in
NavigationLink(
destination: DetailView(city: city)) {
VStack(alignment: .leading) {
Text("\(city.name)")
}
}
}
}
}
}
.navigationBarTitle("City list")
.onTapGesture {
if self.keyboardOpen {
UIApplication.shared.endEditing()
self.keyboardOpen = false
}
}
}
}
Do you know if it's possible to keep both gesture tap and follow to detail view?
Actually it should work, but it is not due to bug of .all GestureMask. I submitted feedback to Apple #FB7672055, and recommend to do the same for everybody affected, the more the better.
Meanwhile, here is possible alternate approach/workaround to achieve similar effect.
Tested with Xcode 11.4 / iOS 13.4
extension UIApplication { // just helper extension
static func endEditing() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to:nil, from:nil, for:nil)
}
}
struct TestEndEditingOnNavigate: View {
#State private var cities = ["London", "Berlin", "New York"]
#State private var searchTerm = ""
#State private var tappedLink: String? = nil // track tapped link
var body: some View {
NavigationView {
Form {
Section {
TextField("Search", text: $searchTerm)
}
Section {
List {
ForEach(cities, id: \.self) { city in
self.link(for: city) // decompose for simplicity
}
}
}
}
.navigationBarTitle("City list")
}
}
private func link(for city: String) -> some View {
let selection = Binding(get: { self.tappedLink }, // proxy bindng to inject...
set: {
UIApplication.endEditing() // ... side effect on willSet
self.tappedLink = $0
})
return NavigationLink(destination: Text("city: \(city)"), tag: city, selection: selection) {
Text("\(city)")
}
}
}
I think you could easily handle this scenario with boolean flags, when your keyboard opens you can set a flag as true and when it dismisses a the flag goes back to false, so in that case when the keyboard is open and the tap gesture is triggered you can check if the keyboard flag is active and not go to detail but instead effectively dismiss the keyboard and viceversa. Let me know if maybe I misunderstood you.

SwiftUI : Dismiss modal from child view

I'm attempting to dismiss a modal after its intended action is completed, but I have no idea how this can be currently done in SwiftUI. This modal is triggered by a #State value change. Would it be possible to change this value by observing a notification of sorts?
Desired actions: Root -> Initial Modal -> Presents Children -> Dismiss modal from any child
Below is what I've tried
Error: Escaping closure captures mutating 'self' parameter
struct AContentView: View {
#State var pageSaveInProgress: Bool = false
init(pages: [Page] = []) {
// Observe change to notify of completed action
NotificationCenter.default.publisher(for: .didCompletePageSave).sink { (pageSaveInProgress) in
self.pageSaveInProgress = false
}
}
var body: some View {
VStack {
//ETC
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
ModalWithChildren test action
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave, object: nil)},
label: { Text("Close") })
You can receive messages through .onReceive(_:perform) which can be called on any view. It registers a sink and saves the cancellable inside the view which makes the subscriber live as long as the view itself does.
Through it you can initiate #State attribute changes since it starts from the view body. Otherwise you would have to use an ObservableObject to which change can be initiated from anywhere.
An example:
struct MyView : View {
#State private var currentStatusValue = "ok"
var body: some View {
Text("Current status: \(currentStatusValue)")
}
.onReceive(MyPublisher.currentStatusPublisher) { newStatus in
self.currentStatusValue = newStatus
}
}
A complete example
import SwiftUI
import Combine
extension Notification.Name {
static var didCompletePageSave: Notification.Name {
return Notification.Name("did complete page save")
}
}
struct OnReceiveView: View {
#State var pageSaveInProgress: Bool = true
var body: some View {
VStack {
Text("Usual")
.onReceive(NotificationCenter.default.publisher(for: .didCompletePageSave)) {_ in
self.pageSaveInProgress = false
}
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
struct ModalWithChildren: View {
#State var presentChildModals: Bool = false
var body: some View {
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave,
object: nil
)
}) { Text("Send message") }
}
}

How to check validation of TextField on Button Tap (not by using NavigationLink) in SwiftUI?

I want to validate that my TextField "username" and TextField "password" are not empty before pushing to next screen. Is it possible in SwiftUI?
Example I do this in Swift:-
#IBAction func btnSignInTapped(_ sender: UIButton) {
if (String.isStringEmpty(aString: getString(anything: tfUserName.text))) {
//Error message
return
} else if (String.isStringEmpty(aString: getString(anything: tfPassword.text))) {
//Error message
return
} else {
//Push screen
}
}
Similarly how can I do that in SwiftUI?
It's easy to do once you embrace the "declarative" nature of SwiftUI. Here's a Swift playground showing a very barebones approach:
import SwiftUI
import PlaygroundSupport
struct ContentView : View {
#State private var email: String = ""
#State private var password: String = ""
private var validated: Bool {
!email.isEmpty && !password.isEmpty
}
var body: some View {
Group {
TextField("email", text: $email)
TextField("password", text: $password)
if validated {
Button("Login") {
print("Logging in")
}
}
}
}
}
PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
Basically you don't work on the TextField but on the #State and instruct the UI to show the button only when the conditions are met.
Of course, if the validation is more complex than what we have here you may want to use a View-Model object instead.

Resources