SWIFTUI/iOS16 ToolbarItem placement .keyboard; button disappears after first tap / use?
Asked Answered
I

2

1

I added a toolbar button to the keyboard and on tapping it, the focus shifts to the next field. Tapping it again shift to the next field, and so on... On the simulator this works flawlessly. In fact, the keyboard stays up all the time and the button is always there 9as it should be). HOWEVER! On a real device after tapping the button the keyboard shortly disappears and when it pops up again, the next button is no longer there. Only exiting the view completely and re-entering will make the button show again, once!

Only differences between simulator and real device is the iOS version: Simulator 16.2, real device 16.3.1. I doubt that matters, also simulator cannot go higher at the moment.

Any ideas on how to fix this for real devices?

            .toolbar {
                ToolbarItem(placement: .keyboard) {
                    HStack {
                        EmptyView()
                            .frame(maxWidth: .infinity, alignment: .leading)
                        
                        Button {
                            setNextFocus()
                        } label: {
                            HStack {
                                Text("Next")
                            Image(systemName: "rectangle.trailinghalf.inset.filled.arrow.trailing")
                            }
                            .foregroundColor(.systemOrange)
                        }
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)
                }
            }

ADDED complete view code (Note: View is presented using a sheet (fullScreenCover))

import SwiftUI
import Combine

struct GameDetailsView: View {
    // MARK: - Properties & State
    var game: Game
    
    @State private var selectedImage: UIImage?
    
    @EnvironmentObject var gamesViewModel: GamesViewModel
    @EnvironmentObject var dataController: DataController
    @Environment(\.dismiss) var dismiss
    
    @State private var showingDeleteAlert = false
    @State private var showingImagePickerOptions = false
    @State private var imagePickerSource: ImagePickerSource? = nil
    
    @FocusState private var focus: AnyKeyPath?
    
    @State private var title: String
    @State private var comments: String
    @State private var platform: String
    @State private var genre: String
    @State private var year: String
    @State private var publisher: String
    @State private var developer: String
    @State private var iconImageURL: String
    @State private var iconImage: Data
    @State private var creationDate: Date
    @State private var lastEdited: Date
    
    // MARK: - Computed Properties (views)
    var gameImage: Image {
        if let selectedImage = selectedImage {
            return Image(uiImage: selectedImage)
        } else {
            return Image(uiImage: game.CoreDataImage)
        }
    }
    
    // MARK: - Body
    var body: some View {
        NavigationStack {
            ZStack(alignment: .bottom) {
                VStack {
                    // Dismiss
                    HStack {
                        Spacer()
                        
                        Button {
                            dismiss()
                        } label: {
                            Image(systemName: "x.square")
                        }
                        .font(.title2)
                        .foregroundColor(.systemPurple)
                    }
                    .padding([.horizontal, .top])
                    .padding(.bottom, 5)
                    
                    ScrollView {
                        // Required Game details
                        VStack {
                           Text("Required Game details")
                                .padding(.top)
                                .font(.title2)
                            
                            Text("Tap on the logo image to select an image for the game!")
                                .font(.caption)
                                .lineLimit(2)

                            HStack {
                                Spacer()
                                
                                gameImage
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                                    .frame(width: 150, height: 150)
                                    .cornerRadius(8)
                                    .onTapGesture {
                                        showingImagePickerOptions = true
                                    }
                                
                                Spacer()
                            }
                            .padding()
                            
                            TextField("Game title", text: $title, onCommit: setNextFocus)
                                .focused($focus, equals: \Self.title)

                            TextField("Platform", text: $platform, onCommit: setNextFocus)
                                .focused($focus, equals: \Self.platform)
                            
                            TextField("Year of release", text: $year.max(4), onCommit: setNextFocus)
                                .focused($focus, equals: \Self.year)
                                .keyboardType(.numbersAndPunctuation)
                            //limit to numbers only (on all platforms)
                                .onReceive(Just(year)) { newValue in
                                    let filtered = newValue.filter { Set("0123456789").contains($0) }
                                    if filtered != newValue {
                                        self.year = filtered
                                    }
                                }
                        }
                        .padding(.bottom)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(.orange, lineWidth: 2)
                        )
                        .padding()
                        
                        // Additional details
                        VStack {
                            Text("Additional details")
                                .padding(.top)
                                .font(.title2)

                            TextEditor(text: $comments)
                                .focused($focus, equals: \Self.comments)
                                .padding(.horizontal)
                                .frame(minHeight: 88)
                                .scrollContentBackground(.hidden)
                                .background(.orange.opacity(0.2))
                                .cornerRadius(8)
                                .foregroundColor(.white)
                                .padding(.horizontal)
                                .overlay(alignment: .leading) {
                                    (comments.isReallyEmpty ? Text("Comments").foregroundColor(.white.opacity(0.3)) : Text("")).padding(.leading, 30)
                                }
                            
                            TextField("Genre", text: $genre, onCommit: setNextFocus)
                                .focused($focus, equals: \Self.genre)
                            
                            TextField("publisher", text: $publisher, onCommit: setNextFocus)
                                .focused($focus, equals: \Self.publisher)
                            
                            TextField("Developer", text: $developer, onCommit: setNextFocus)
                                .focused($focus, equals: \Self.developer)
                        }
                        .padding(.bottom)
                        .background(RoundedRectangle(cornerRadius: 8)
                            .strokeBorder(.orange, lineWidth: 2))
                        .padding()
                        
                        // Dates
                        VStack {
                            Text("Created")
                                .padding(.top)
                                .padding(.bottom, 5)
                                .font(.title2)
                            
                            Text("Created on \(creationDate.customMediumToString)\nLast edited on \(lastEdited.customMediumToString)")
                                .font(.caption)
                                .lineLimit(2)

                        }
                        .frame(maxWidth: .infinity)
                        .padding(.bottom)
                        .background(RoundedRectangle(cornerRadius: 8)
                            .strokeBorder(.orange, lineWidth: 2))
                        .padding()
                        // Make sure we can scroll all the way down and not be obscured by the buttons
                        .padding(.bottom, 80)
                    }
                }
                .textFieldStyle(GamesTextFieldStyle())
                .alert(isPresented: $showingDeleteAlert) {
                    Alert(title: Text("Delete this Game?"),
                          message: Text("Are you sure you want to delete this Game?\nAll related annotations and images will also be deleted!\nThis can not be undone!"),
                          primaryButton: .cancel(),
                          secondaryButton: .destructive(Text("Delete"), action: delete))
                }
                .confirmationDialog("Select", isPresented: $showingImagePickerOptions) {
                    Button("Camera") { imagePickerSource = .camera }
                    Button("Photo library") { imagePickerSource = .photos }
                    Button("Documents") { imagePickerSource = .documents }
                    Button("Web") { imagePickerSource = .web }
                    Button("Paste from clipboard") { pasteImageFromClipboard() }
                    Button("Cancel", role: .cancel) { }
                } message: {
                    Text("Select an Image source...")
                }
                .sheet(item: $imagePickerSource) { source in
                    switch source {
                    case .camera:
                        CameraPicker(selectedImage: $selectedImage)
                    case .photos:
                        CameraPicker(sourceType: .photoLibrary, selectedImage: $selectedImage)
                    case .documents:
                        DocumentsPicker(selectedImage: $selectedImage)
                    case .web:
                        SafariWebPickerWithOverlay(selectedImage: $selectedImage)
                    }
                }
                .onDisappear(perform: deleteInvalidNewGame)
            }
            // Hide the keyboard when tapped anywhere in the view not user interactive!
            .onTapGesture {
                withAnimation(.easeInOut) {
                    self.hideKeyboard()
                }
            }
            .toolbar {
                #warning("TO DO: Bug, on real devices/iphones the keyboard button disappears upon losing focus by either manually tapping on another field or tapping the button")
                ToolbarItemGroup(placement: .keyboard) {
                    HStack {
                        EmptyView()
                            .frame(maxWidth: .infinity, alignment: .leading)
                        
                        Button {
                            setNextFocus()
                        } label: {
                            HStack {
                                Text("Next")
                                Image(systemName: "rectangle.trailinghalf.inset.filled.arrow.trailing")
                            }
                            .foregroundColor(.systemOrange)
                        }
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)
                }
            }
            .safeAreaInset(edge: .bottom) {
                // Save and Delete Buttons
                // Slide up with the keyboard when it appears
                HStack {
                    Button {
                        print("Save tapped")
                        save()
                    } label: {
                        HStack {
                            Text("Save")
                            AppSymbols.save
                        }
                    }
                    .frame(height: 44)
                    .frame(maxWidth: .infinity)
                    .background {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(.orange)
                    }
                    .disabled(missingRequiredGameDetails())
                    .opacity(missingRequiredGameDetails() ? 0.3 : 1)

                    Spacer(minLength: 20)

                    Button {
                        print("Delete tapped")
                        showingDeleteAlert.toggle()
                    } label: {
                        HStack {
                            Text("Delete")
                            AppSymbols.delete
                        }
                    }
                    .frame(height: 44)
                    .frame(maxWidth: .infinity)
                    .background {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(.red)
                    }
                }
                .padding()
                .background(.ultraThinMaterial)
                .foregroundColor(.white)
            }
            .foregroundColor(.white)
            .background(
                LinearGradient(colors: [
                    Color.systemOrange,
                    Color.systemPurple,
                    Color.systemPurple,
                    Color.systemPurple,
                    Color.systemPurple
                ], startPoint: .top, endPoint: .bottom)
        )
        }
    }
    
    // MARK: - Init
    init(game: Game) {
        self.game = game
        
        //populate the local @State properties
        _title = State(wrappedValue: game.viewTitle)
        _comments = State(wrappedValue: game.viewComments)
        _platform = State(wrappedValue: game.viewPlatform)
        _genre = State(wrappedValue: game.viewGenre)
        _year = State(wrappedValue: game.viewYear)
        _publisher = State(wrappedValue: game.viewPublisher)
        _developer = State(wrappedValue: game.viewDeveloper)
        _iconImageURL = State(wrappedValue: game.viewIconImageURL)
        _iconImage = State(wrappedValue: game.viewIconImage)
        _creationDate = State(wrappedValue: game.viewCreationDate)
        _lastEdited = State(wrappedValue: game.viewLastEdited)
        
        // enable different background colors form / TextEditor
        UITableView.appearance().backgroundColor = .clear
        UITextView.appearance().backgroundColor = .clear
    }
    
    // MARK: - Methods
    // Shifts focus to next Textfield on enter
    func setNextFocus() {
        switch focus {
        case \Self.title:
            focus = \Self.platform
        case \Self.platform:
            focus = \Self.year
        case \Self.year:
            focus = \Self.comments
        case \Self.comments:
            focus = \Self.genre
        case \Self.genre:
            focus = \Self.publisher
        case \Self.publisher:
            focus = \Self.developer
        default:
            focus = \Self.title
        }
    }
    
    private func pasteImageFromClipboard() {
        let pasteboard = UIPasteboard.general
        
        //image was returned by Copy
        if pasteboard.hasImages {
            guard let image = pasteboard.image else { return }
            selectedImage = image
            //Image Url was returned by Copy
        } else if pasteboard.hasURLs {
            guard let url = pasteboard.url else { return }
            if let data = try? Data(contentsOf: url) {
                if let image = UIImage(data: data) {
                    selectedImage = image
                }
            }
        }
        pasteboard.items.removeAll()
    }
    
    // Minimum details required to be a valid game
    private func missingRequiredGameDetails() -> Bool {
        title.isReallyEmpty || platform.isReallyEmpty || year.isReallyEmpty
    }
    
    // On dismiss , delete a newly added Game that hasn't the minimum required details
    private func deleteInvalidNewGame() {
        guard title == "New Game", missingRequiredGameDetails() == true else { return }
        gamesViewModel.delete(game)
    }
    
    func update() {
        game.title = title
        game.comments = comments
        game.platform = platform
        game.genre = genre
        game.year = year
        game.publisher = publisher
        game.developer = developer
        game.lastEdited = Date.now
        if let selectedImage = selectedImage {
            game.iconImage = selectedImage.jpegData(compressionQuality: 1.0)
        }
    }
    
    func save() {
        update()
        gamesViewModel.save(game)
        dismiss()
    }
    
    func delete() {
        gamesViewModel.delete(game)
        dismiss()
    }
}

Next Button

Intuitionism answered 14/3, 2023 at 17:4 Comment(0)
I
1

I solved it! It's a navigation issue: The GameDetailsView is presented as a FullScreenCover. The View that presents it is part of a NavigationStack with a path (stored in A Router object, which is part of the Environment)

class Router: ObservableObject {
    @Published var path = NavigationPath()
    
    func reset() {
      path = NavigationPath()
    }
}

The solution is to add the environment variable to the top of the View and replacing plain NavigationStack with the router parameter:

@EnvironmentObject var router: Router

(...)

NavigationStack(path: $router.path) {
   // View Contents
}

This also makes that using a Spacer() works like it should! The Toolbar code is now:

            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                        Spacer()
                        Button {
                            setNextFocus()
                        } label: {
                            HStack {
                                Text("Next")
                                Image(systemName: "rectangle.trailinghalf.inset.filled.arrow.trailing")
                            }
                            .foregroundColor(.systemOrange)
                        }
                }
            }

So glad! Thanks!

Intuitionism answered 16/3, 2023 at 12:59 Comment(2)
This worked for me too - strange iOS bug from my point of view (still around in iOS 17.1 RC). Thank you!Schoonover
App crashes due to Fatal error: No ObservableObject of type Router found. A View.environmentObject(_:) for Router may be missing as an ancestor of this view.Hudak
D
3

Everything is fine. Just change ToolbarItem to ToolbarItemGroup Like this:

         .toolbar {
                ToolbarItemGroup(placement: .keyboard){
                    HStack {
                        EmptyView()
                            .frame(maxWidth: .infinity, alignment: .leading)
                        
                        Button{
                            setNextFocus()
                        } label: {
                            HStack {
                                Text("Next")
                                Image(systemName: "rectangle.trailinghalf.inset.filled.arrow.trailing")
                            }
                            .foregroundColor(.orange)
                        }.frame(maxWidth: .infinity, alignment: .trailing)
                    }
                }
            }
Desdee answered 14/3, 2023 at 18:17 Comment(6)
Tried that. Doesn't change a thing on my real. device. Does it matter where I add the Toolbar with ToolbarItemGroup? Also, when I change the placement to .navigationBarLeading the button stays as expected, but of coarse I want it on the keyboard ....Intuitionism
The button disappears also when I manually tap/enter into a new TextFieldIntuitionism
It worked first time for me, but I will try to get a code stable for all devices and return to you.Desdee
Thank you. Would it hep to have my complete View Code?Intuitionism
Yes, because sometimes the code is missing a NavigationStack will destroy your code. That happened to me very much.Desdee
Added the complete View Code. (Note: View is presented using a sheet (fullScreenCover))Intuitionism
I
1

I solved it! It's a navigation issue: The GameDetailsView is presented as a FullScreenCover. The View that presents it is part of a NavigationStack with a path (stored in A Router object, which is part of the Environment)

class Router: ObservableObject {
    @Published var path = NavigationPath()
    
    func reset() {
      path = NavigationPath()
    }
}

The solution is to add the environment variable to the top of the View and replacing plain NavigationStack with the router parameter:

@EnvironmentObject var router: Router

(...)

NavigationStack(path: $router.path) {
   // View Contents
}

This also makes that using a Spacer() works like it should! The Toolbar code is now:

            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                        Spacer()
                        Button {
                            setNextFocus()
                        } label: {
                            HStack {
                                Text("Next")
                                Image(systemName: "rectangle.trailinghalf.inset.filled.arrow.trailing")
                            }
                            .foregroundColor(.systemOrange)
                        }
                }
            }

So glad! Thanks!

Intuitionism answered 16/3, 2023 at 12:59 Comment(2)
This worked for me too - strange iOS bug from my point of view (still around in iOS 17.1 RC). Thank you!Schoonover
App crashes due to Fatal error: No ObservableObject of type Router found. A View.environmentObject(_:) for Router may be missing as an ancestor of this view.Hudak

© 2022 - 2024 — McMap. All rights reserved.