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()
}
}