Thanks for trying to help me!
The EmptyView() is there because when I put a Spacer() there, the button disappears completely!
I've a slightly different setup of focusState.... The View is presented as a sheet (FullScreenCover)
Here's the complete View Code (it's a bit big, will be refactored at some point, for now I'm trying to get the last bits working and improved!)
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()
}
}