Document based app shows 2 back chevrons on iPad - swiftui

I did a small sample application to show my problem. I used the multi-platform document app template that Xcode 14.0.1 offers, creating my own package file format for this.
I want to create a document based app running on macOS and on iPad.
When running on macOS, everything works as expected.
On the iPad, when opening the app, the file chooser opens.
On opening an existing or creating a new file, the screen looks like this:
The left chevron does nothing, while the right chevron shows the document chooser again.
What's the left, ever so slightly larger chevron on the left doing here and how can I get of it? Is this an error with the framework that should be reported to Apple?
PS don't get distracted by the name of this sample app–the real app will need some navigation and I first thought the 2nd chevron show up cause of this–in the sample I built for this post, there is no navigation though. So this 2nd chevron seems to be a "built in" issue...
For the sake of completeness, here's my code:
import SwiftUI
#main
struct so_DocumentAppWithNavigationShowsMultipleChevronsApp: App {
var body: some Scene {
DocumentGroup(newDocument: so_DocumentAppWithNavigationShowsMultipleChevronsDocument()) { file in
ContentView(document: file.$document)
}
}
}
import UniformTypeIdentifiers
extension UTType {
static var appfroschFile: UTType {
UTType(importedAs: "ch.appfros.so-DocumentAppWithNavigationShowsMultipleChevrons")
}
}
struct so_DocumentAppWithNavigationShowsMultipleChevronsDocument: FileDocument {
var document: Document
init(document: Document = Document(text: "Hello, world!")) {
self.document = document
}
static var readableContentTypes: [UTType] { [.appfroschFile] }
init(configuration: ReadConfiguration) throws {
guard let fileWrappers = configuration.file.fileWrappers
else {
throw CocoaError(.fileReadCorruptFile)
}
guard let documentFileWrapper = fileWrappers["document"],
let data = documentFileWrapper.regularFileContents,
let string = String(data: data, encoding: .utf8)
else {
throw CocoaError(.fileReadCorruptFile)
}
document = try JSONDecoder().decode(Document.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(document)
let documentFileWrapper = FileWrapper(regularFileWithContents: data)
let mainFileWrapper = FileWrapper(directoryWithFileWrappers: [
"document": documentFileWrapper
])
return mainFileWrapper
}
}
struct Document: Codable {
var text: String
}
struct ContentView: View {
#Binding var document: so_DocumentAppWithNavigationShowsMultipleChevronsDocument
var body: some View {
TextEditor(text: $document.document.text)
}
}

Can see the same problem with the default Document based app when using Xcode 14.1 (14B47) running on the iPad simulator with iOS 16.1. So definitely a bug (and worth reporting to A as such).
At a guess, the second, non-functional back button is what would have been the back button for navigating in SideBar. And the logic to not display when working on a document is what has been broken.
Fortunately simple workaround for the bug is to explicitly specify toolbar's role using the toolbarRole modifier, e.g.
#main
struct so_DocumentAppWithNavigationShowsMultipleChevronsApp: App {
var body: some Scene {
DocumentGroup(newDocument: so_DocumentAppWithNavigationShowsMultipleChevronsDocument()) { file in
ContentView(document: file.$document)
.toolbarRole(.navigationStack) // <-- Specifying this gets rid of double chevron on iOS
}
}
}

Related

How to respond to hover / click event on AttributedString in SwiftUI

I am using AttributedString in SwiftUI (Mac application) to customize the appearance of portions of a long string. I'm displaying the text formatted successfully and it appears correct.
My code looks like this:
struct TextView: View {
var body: some View {
ScrollView {
Text(tag())
}.padding()
}
func tag() -> AttributedString {
// code which creates the attributed string and applies formatting to various locations
}
}
At this point I want to add "touch points" ("interactive points") to the text (imagine hyperlinks) which will provide additional information when particular locations (pieces of text) are interacted with.
Ive seen some similar questions describing usage (or combinations) of NSTextAttachment , NSAttributedStringKey.link , UITextViewDelegate
see:
NSAttributedString click event in UILabel using Swift
but this isn't (or at least not obvious) the idiomatic "SwiftUI" way and seems cumbersome.
I would want to tag the string with the formatting while adding the "Attachment" which can be recognized in the view event handler:
func tag() -> AttributedString {
// loose for this example
var attributedString = AttributedString("My string which is very long")
for range in getRangesOfAttributes {
attributedString[range].foregroundColor = getRandomColor()
attributedString[range].attachment = Attachment() <<<<<<< this is missing, how do I tag this portion and recognize when it got interacted with in the View
}
}
func getRangesOfAttributes() -> ClosedRange<AttributedString.Index> {
... returns a bunch of ranges which need to be tagged
}
// the view can now do something once the attachment is clicked
var body: View {
Text(tag())
.onClickOfAttachment(...) // <<<< This is contrived, how can I do this?
}

SwiftUI run function every time app is opened

I have a very troubling problem. I have searched for days on how to solve it. I have some code that I want to run every time the app is opened, not just when the app is launched for the first time. I've basically tried everything available. I've tried scenePhase, I've tried AppDelegate, I've tried onAppear, I've tried init and custom extensions to the View class, I've even tried simply running a function in the view, but nothing is working. I'll show my code here.
#main
struct CouponDeckApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
AppContentView()
}
}
}
struct AppContentView: View {
init() {
let userDefaults = UserDefaults.standard
if userDefaults.value(forKey: "hour") == nil { // 1
userDefaults.set(9, forKey: "hour") // 2
}
// 3
if userDefaults.value(forKey: "minute") == nil {
userDefaults.set(30, forKey: "minute")
}
}
#State var currentview: String = "Main"
var body: some View {
Group {
switch currentview {
case "Main":
MainView(currentview: $currentview)
case "Form":
FormView(currentview: $currentview)
case "Settings":
SettingsView(currentview: $currentview)
default:
if currentview.contains("Coupon") {
CouponView(currentview: $currentview)
}
else {
EditView(currentview: $currentview)
}
}
}
}
}
//MainView(), CouponView(), FormView(), etc.
I'm starting to suspect that the problem is with the switch statement in AppContentView that allows you to move between the different views.
Does anyone know:
A. Why this is happening,
B. How to fix it, or
C. Another alternative?
Thanks in advance!
P.S. I'm running my code on the simulator.
Here is a very simple way, using native scenePhase, I did not make it more complicated. You can use Preference method as well for better result! But onChange is good enough for this example:
struct ContentView: View {
#Environment(\.scenePhase) var scenePhase
var body: some View {
Text("Welcome to my App!")
.onAppear() { customFunction() }
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
customFunction()
}
}
}
}
func customFunction() {
print("App is opened!")
}
The simple problem was that it doesn't work when you close out of the app. I realized if you just exit the app but don't completely close out of it, it works just fine.
I also learned about the NotificationCenter's applications to this. By triggering a response when UIApplication sends out the willEnterForegroundNotification by using the onReceive method, you can trigger a response that way.
Do it in your AppDelegate's application(_:didFinishLaunchingWithOptions:).

How do I hide default CommandMenu in SwiftUI on macOS?

I'm (attempting) switching over my AppDelegate macOS app to the SwiftUI lifecycle - but can't seem to find out how to handle the CommandMenu. I just want to delete these default menu items (Fie, Edit, View, etc...). In the past, I would just delete them from the Storyboard - but I'm not using a storyboard here. Is there a way to delete these items in SwiftUI?
The items I want to delete:
I know how to add new items via:
.commands {
MyAppMenus()
}
But that just adds them inline with the existing menu items.
swiftUI -- override AppDelegate with your custom:
#main
struct PixieApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
///........
}
code of appDelegate:
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillUpdate(_ notification: Notification) {
if let menu = NSApplication.shared.mainMenu {
menu.items.removeFirst{ $0.title == "Edit" }
menu.items.removeFirst{ $0.title == "File" }
menu.items.removeFirst{ $0.title == "Window" }
menu.items.removeFirst{ $0.title == "View" }
}
}
}
result:
Until SwiftUI adds more support for adjusting menus, I think you have to worry about SwiftUI reseting the NSApp.mainMenu whenever it updates a window.body. I haven't tried every method for adjusting the mainMenu, but of the methods I tried, the flaw was that SwiftUI seems to have no check for whether it last set NSApp.mainMenu or if something else did.
So however you are managing the menu, update it after SwiftUI has.
Use KVO and watch the NSApp for changes on .mainMenu. Then make your changes with a xib, or reseting the whole thing, or editing SwiftUI's menus.
Example:
#objc
class AppDelegate: NSObject, NSApplicationDelegate {
var token: NSKeyValueObservation?
func applicationDidFinishLaunching(_ notification: Notification) {
// Adjust a menu initially
if let m = NSApp.mainMenu?.item(withTitle: "Edit") {
NSApp.mainMenu?.removeItem(m)
}
// Must refresh after every time SwiftUI re adds
token = NSApp.observe(\.mainMenu, options: .new) { (app, change) in
// Refresh your changes
guard let menu = app.mainMenu?.item(withTitle: "Edit") else { return }
app.mainMenu?.removeItem(menu)
}
}
}
struct MarblesApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some View {
//...
}
}
This seems to work in Xcode 13.4.1 with Swift 5 targeting macOS 12.3.
Hopefully Apple adds greater control soon. It seems Catalyst has other options. Or you can create a traditional AppKit app and insert the SwiftUI views into it.
You can remove command menu items through the AppDelegate file:
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
builder.remove(menu: .services)
builder.remove(menu: .format)
builder.remove(menu: .toolbar)
}
This thread on the Apple Developer forum might help as well: https://developer.apple.com/forums/thread/649096
CommandGroup(replacing: CommandGroupPlacement.appVisibility, addition: {})

SwiftUI - ReferenceFileDocument - Inability to indicate a document needs saving

When creating a class conforming to ReferenceFileDocument, how do you indicate the document needs saving. i.e. the equivalent of the NSDocument's updateChangeCount method?
I've met the same problem that the SwiftUI ReferenceFileDocument cannot trigger the update. Recently, I've received feedback via the bug report and been suggested to register an undo.
Turns out the update of ReferenceFileDocument can be triggered, just like UIDocument, by registering an undo action. The difference is that the DocumentGroup explicitly implicitly setup the UndoManager via the environment.
For example,
#main
struct RefDocApp: App {
var body: some Scene {
DocumentGroup(newDocument: {
RefDocDocument()
}) { file in
ContentView(document: file.document)
}
}
}
struct ContentView: View {
#Environment(\.undoManager) var undoManager
#ObservedObject var document: RefDocDocument
var body: some View {
TextEditor(text: Binding(get: {
document.text
}, set: {
document.text = $0
undoManager?.registerUndo(withTarget: document, handler: {
print($0, "undo")
})
}))
}
}
I assume at this stage, the FileDocument is actually, on iOS side, a wrapper on top of the UIDocument, the DocumentGroup scene explicitly implicitly assign the undoManager to the environment. Therefore, the update mechanism is the same.
The ReferenceFileDocument is ObservableObject, so you can add any trackable or published property for that purpose. Here is a demo of possible approach.
import UniformTypeIdentifiers
class MyTextDocument: ReferenceFileDocument {
static var readableContentTypes: [UTType] { [UTType.plainText] }
func snapshot(contentType: UTType) throws -> String {
defer {
self.modified = false
}
return self.storage
}
#Published var modified = false
#Published var storage: String = "" {
didSet {
self.modified = true
}
}
}
ReferenceFileDocument exists for fine grained controll over the document. In comparison, a FileDocument has to obey value semantics which makes it very easy for SwiftUI to implement the undo / redo functionality as it only needs to make a copy before each mutation of the document.
As per the documentation of the related DocumentGroup initializers, the undo functionality is not provided automatically. The DocumentGroup will inject an instance of an UndoManger into the environment which we can make use of.
However an undo manager is not the only way to update the state of the document. Per this documentation AppKit and UIKit both have the updateChangeCount method on their native implementation of the UI/NSDocument object. We can reach this method by grabbing the shared document controller on macOS from within the view and finding our document. Unfortunately I don't have a simple solution for the iOS side. There is a private SwiftUI.DocumentHostingController type which holds a reference to our document, but that would require mirroring into the private type to obtain the reference to the native document, which isn't safe.
Here is a full example:
import SwiftUI
import UniformTypeIdentifiers
// DOCUMENT EXAMPLE
extension UTType {
static var exampleText: UTType {
UTType(importedAs: "com.example.plain-text")
}
}
final class MyDocument: ReferenceFileDocument {
// We add `Published` for automatic SwiftUI updates as
// `ReferenceFileDocument` refines `ObservableObject`.
#Published
var number: Int
static var readableContentTypes: [UTType] { [.exampleText] }
init(number: Int = 42) {
self.number = number
}
init(configuration: ReadConfiguration) throws {
guard
let data = configuration.file.regularFileContents,
let string = String(data: data, encoding: .utf8),
let number = Int(string)
else {
throw CocoaError(.fileReadCorruptFile)
}
self.number = number
}
func snapshot(contentType: UTType) throws -> String {
"\(number)"
}
func fileWrapper(
snapshot: String,
configuration: WriteConfiguration
) throws -> FileWrapper {
// For the sake of the example this force unwrapping is considered as safe.
let data = snapshot.data(using: .utf8)!
return FileWrapper(regularFileWithContents: data)
}
}
// APP EXAMPLE FOR MACOS
#main
struct MyApp: App {
var body: some Scene {
DocumentGroup.init(
newDocument: {
MyDocument()
},
editor: { file in
ContentView(document: file.document)
.frame(width: 400, height: 400)
}
)
}
}
struct ContentView: View {
#Environment(\.undoManager)
var _undoManager: UndoManager?
#ObservedObject
var document: MyDocument
var body: some View {
VStack {
Text(String("\(document.number)"))
Button("randomize") {
if let undoManager = _undoManager {
let currentNumber = document.number
undoManager.registerUndo(withTarget: document) { document in
document.number = currentNumber
}
}
document.number = Int.random(in: 0 ... 100)
}
Button("randomize without undo") {
document.number = Int.random(in: 0 ... 100)
// Let the system know that we edited the document, which will
// eventually trigger the auto saving process.
//
// There is no simple way to mimic this on `iOS` or `iPadOS`.
let controller = NSDocumentController.shared
if let document = controller.currentDocument {
// On `iOS / iPadOS` change the argument to `.done`.
document.updateChangeCount(.changeDone)
}
}
}
}
}
Unfortunatelly SwiftUI (v2 at this moment) does not provide a native way to mimic the same functionality, but this workaround is still doable and fairly consice.
Here is a gist where I extended the example with a custom DocumentReader view and a DocumentProxy which can be extended for common document related operations for more convenience: https://gist.github.com/DevAndArtist/eb7e8aa5e7134610c20b1a7aca358604

Getting error "Generic parameter 'Label' could not be inferred" with SwiftUI tutorial code

I'm working through this SwiftUI tutorial here:
https://developer.apple.com/tutorials/swiftui/handling-user-input
And on Step 3 of the section "Adopt the Model Object in Your View", I get this error on the Toggle statement in line 16: "Generic parameter 'Label' could not be inferred."
My code is identical to that provided in the tutorial:
import SwiftUI
struct LandmarkList: View {
#EnvironmentObject var userData: UserData
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites Only")
}
ForEach(userData.landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}
When I look at the code provided in the "Complete" folder, I see its nearly identical, except that the userData variable is made private—which I added to my "StartingPoint" version, though I can't imagine why it'd make a difference, and of course it still gives the same error and won't build. I can build and run the Complete version, so clearly the message about requiring a Generic parameter is wrong and it must have to do with something else like how the project is configured in settings.
I remember getting stuck earlier this summer with a similar issue in a different part of the tutorial, and found a post where someone explained why code would work in one project and not another, but I can't find that post now.
Is anyone familiar with this issue? Is there something else I need to understand about how to configure my project before I can reference an observable object in a toggle control in my view like this?
So as I mentioned in the comments: Just add .environmentObject(UserData()) under the ForEach() in your LandmarkList_Previews struct.
That would result in:
import SwiftUI
struct LandmarkList: View {
#EnvironmentObject var userData: UserData
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites Only")
}
ForEach(userData.landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)
.environmentObject(self.userData)
) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}.environmentObject(UserData())
}
}
I had the exact same problem today and Ravi Mishra's comment resolved the issue for me (kudos to you). It must've been an autocomplete mistake.
ForEach(userData.landmarkData) should instead be ForEach(userData.landmarks)
I bothered my head about this tutorial for a week, went line by line through it twice by downloading the tutorial files again. I made sure that every character of my work in the tutorial matched every character of the completed project--twice--and still that nasty error message popped up as soon as I entered the final code. Stack Overflow was my last stop; when I read the solution was to include '.environmentObject(UserData())', I went right to my code to add it but it was already there!
Out of pure desperation, I decided to copy the (exact same) code for the body from the source of the completed project to the source in my tutorial project. The error disappeared.
I think #krjw has a point.