This example is pretty contrived, but it illustrates the behavior. I know you can use .accessibilityIdentifier to uniquely identify a control, but I'm just trying to better understand the interplay between XCUIElement and XCUIElementQuery.
Let's say you have an app like this:
import SwiftUI
struct ContentView: View {
#State var showRedButton = true
var body: some View {
VStack {
if showRedButton {
Button("Click me") {
showRedButton = false
}
.background(.red)
}
else {
HStack {
Button("Click me") {
showRedButton = true
}
.background(.blue)
Spacer()
}
}
}
}
}
And you are UI testing like this:
import XCTest
final class MyAppUITests: XCTestCase {
func testExample() throws {
let app = XCUIApplication()
app.launch()
print(app.debugDescription)
// At this point, the Element subtree shows a single Button:
// Button, 0x14e40d290, {{162.3, 418.3}, {65.3, 20.3}}, label: 'Click me'
let btn = app.buttons["Click me"]
btn.tap() // <-- This tap makes the red button disappear and shows the blue button
print(app.debugDescription)
// Now, the Element subtree shows a single Button that has a different ID
// and different x-y coordinates:
// Button, 0x15dc12e50, {{0.0, 418.3}, {65.3, 20.3}}, label: 'Click me'
btn.tap() // <-- This tap now works on the blue button?? Without requerying?
print(app.debugDescription)
// The red button reappears, but with a different ID (which makes sense).
}
}
Why does the second tap work, even though it's a different control? This must mean that SwiftUI is automatically re-running the XCUIElementQuery to find the button that matches "Click me". Apparently the variable btn isn't linked to the control with the ID 0x14e40d290. Does this mean XCUIElement actually represents an XCUIElementQuery? I expected it to require me to explicitly re-run the query like this,
btn = app.buttons["Click me"]
prior to running the 2nd tap, or the tap would've said that btn was no longer available.
The final print of the Element subtree shows that the red button has a different ID now. This makes sense, because when SwiftUI redraws the red button, it's not the same instance as the last time. This is explained well in the WWDC videos. Nevertheless, at the moment I connected the variable "btn" to the control, I thought there was a tighter affiliation. Maybe UI testing has to behave this way because SwiftUI redraws controls so frequently?
Related
In the following example code, a SwiftUI form holds an Observable object that holds a trivial pipeline that passes a string through to a #Published value. That object is being fed by the top line of the SwiftUI form, and the output is being displayed on the second line.
The value in the text field in the first row gets propagated to the output line in the second row, whenever we hit the "Send" button, unless we hit the "End" button, which cancels the subscription, as we'd expect.
import SwiftUI
import Combine
class ResetablePipeline: ObservableObject {
#Published var output = ""
var input = PassthroughSubject<String, Never>()
init(output: String = "") {
self.output = output
self.input
.assign(to: &$output)
}
func reset()
{
// What has to go here to revive a completed pipeline?
self.input
.assign(to: &$output)
}
}
struct ResetTest: View {
#StateObject var pipeline = ResetablePipeline()
#State private var str = "Hello"
var body: some View {
Form {
HStack {
TextField(text: $str, label: { Text("String to Send")})
Button {
pipeline.input.send(str)
} label: {
Text("Send")
}.buttonStyle(.bordered)
Button {
pipeline.input.send(completion: .finished)
} label: {
Text("End")
}.buttonStyle(.bordered)
}
Text("Output: \(pipeline.output)")
Button {
pipeline.reset()
} label: {
Text("Reset")
}
}
}
}
struct ResetTest_Previews: PreviewProvider {
static var previews: some View {
ResetTest()
}
}
My understanding is that hitting "End" and completing/cancelling the subscription will delete all the Combine nodes that were set up in the ResetablePipeline.init function (currently only the assign operator).
But if we wanted to reset that connection, how would we do that (without creating a new ResetablePipeline object). What would you have to do in reset() to reconnect the plumbing in the ResetablePipeline object, so that the Send button would work again? Why does the existing code not work?
It is part of the fundamental nature of a Publisher that once the Publisher has finished, or has emitted an error, that the publisher will never emit another value.
This is described in Reactive X in the Observable Contract
The fundamental reason for this is that when the pipeline finishes, the stages in the pipeline are free to release any resources they may have obtained. For example, if a collect operator has set aside memory for its connected items, it can release that memory once the pipeline finishes.
In short, there is no way to do what you want to do. You cannot restart a pipeline that has finished, though you can construct a new one.
Well I'll be. If I simply add input = PassthroughSubject<String, Never>() to the start of reset() (ie replace the original cancelled head-publisher with a fresh one), it seems to do the trick.
Now, I'm not entirely sure if this code is not leaking something since I don't know exactly what assign(to:) does with the old subscription, but assuming that it's sensible, this might be OK.
Can anyone see anything wrong with this approach?
I have scaled down one of the Apps I am working on to use to learn how to program App Intents and utilize Siri in my apps. I will post the scaled-down version of my code below in its simplest form. This is a simple app that merely keeps a total in an #State var named counter. The app shows the current total along with 2 buttons, one button being labeled "minus" and the other labeled "add".
When someone taps the minus button, 1 is subtracted from counter if counter is greater than 0. When someone taps the plus button, 1 is added to counter as long as it is not greater than 10,000.
The buttons actually call functions called decrementCounter and incrementCounter which do the math and update the value of the state variable counter.
The app works just fine as is. Minus and plus buttons work and the view is updated to reflect the current value of counter as buttons are pushed.
The problem is when I try to use an App Intent to add or subtract from counter. In this example, I only put in two App Intents, one to add to counter and the other to have Siri tell you what the current value of counter is.
The app intent called SiriAddOne calls the same function as is used when a button is pressed, however, counter does not get incremented.
Also, the app intent SiriHowMany will always tell you the counter is zero.
It's like the App Intents are not able to access the counter variable used in the view.
I do know that the functions are being called because, in my main program where I extracted this from, the incrementCounter and decrementCounter functions do others things as well. Those other things all work when I use the App Intent to call the function, but the counter variable remains unchanged.
Hopefully, someone can tell me what I am doing wrong here or how I need to go about doing this correctly. Thank you.
import SwiftUI
import AppIntents
struct ContentView: View {
// This variable counter is the only thing that changes
// and should be what forces the view to update
#State var counter = 0
var body: some View {
VStack {
Spacer()
Text("Total")
// Displays the current value of the counter
Text(String(counter))
Spacer()
// When button is pressed call decrementCounter function
Button(action: {
decrementCounter()
}, label: {
Text("Minus")
})
Spacer()
// When button is pressed call incrementCounter function
Button(action: {
incrementCounter()
}, label: {
Text("Add")
})
Spacer()
}
.padding()
}
// subtract 1 from the counter
// when this happens the view should update to
// to reflect the new value.
func decrementCounter() {
if counter > 0 {
counter -= 1
}
return
}
// Add 1 to the counter
// when this happens the view should update to
// to reflect the new value.
func incrementCounter() {
if counter <= 9999 {
counter += 1
}
return
}
// Set up App Intent, perform action when matched
// and have siri state it has been done.
#available(iOS 16, *)
struct SiriAddOne: AppIntent {
static var title: LocalizedStringResource = "Add 1"
static var description = IntentDescription("Adds 1 to the counter")
#MainActor
func perform() async throws -> some IntentResult {
ContentView().incrementCounter()
return .result(dialog: "Okay, added 1 to counter.")
}
}
// Set up App Intent, perform action when matched
// and have siri state the current value of the counter.
#available(iOS 16, *)
struct SiriHowMany: AppIntent {
static var title: LocalizedStringResource = "How Many"
static var description = IntentDescription("How Many?")
func perform() async throws -> some IntentResult {
return .result(dialog: "You have \(ContentView().counter).")
}
}
// Defines the two shortcut phrases to be used to call the two AppIntents
#available(iOS 16, *)
struct SiriAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SiriAddOne(),
phrases: ["Add one to \(.applicationName)"]
)
AppShortcut(
intent: SiriHowMany(),
phrases: ["How many \(.applicationName)"]
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I have a navigation title that is too large on some smaller devices.
I've read many ways to set the titleTextAttributes and largeTitleTextAttributes of the UINavigationBar.appearance() however when setting the paragraph style to word wrap, it seems to remove the standard ... clipping and have the text continue off the edge of the screen without wrapping:
init() {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
UINavigationBar.appearance().largeTitleTextAttributes = [
.paragraphStyle: paragraphStyle
]
}
I want to maintain the SwiftUI behaviour where the title is shown as large text until the view is scrolled up and it moves to the navigation bar, so getting the .toolbar directly won't help.
I also don't want to just specify a smaller font as I only want it to shrink or wrap if necessary.
Has anyone managed to achieve this?
You can add this line on the initializer of the view where yo have the issue
UILabel.appearance(whenContainedInInstancesOf: [UINavigationBar.self]).adjustsFontSizeToFitWidth = true
Example:
struct YourView: View {
init() {
UILabel.appearance(whenContainedInInstancesOf: [UINavigationBar.self]).adjustsFontSizeToFitWidth = true
}
var body: some View {
NavigationView {
Text("Your content")
.navigationBarTitle("Very very large title to fit in screen")
}
}
}
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: {})
Ok -
I want a picker view to pick one operator: "=","<",">"
This operator will be sent as a binding:
#Binding var op:String
My Picker:
Picker(selection: binding, label: Text("Query Type")) {
ForEach(0..<self.operators.count) { index in
Text(self.operators[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
.padding()
Now My Binding with CallBack:
let binding = Binding<Int>(
get: {
return self.pickerSelection
},
set: {
//pickerSelection = $0
print("SETTTING: \($0)")
self.op = self.operators[self.pickerSelection]
self.queryCallback()
})
So, I can set the pickers perfectly. BUT, when I go back to edit my data, the picker never can choose the existing bound operator, say "<"
I put in the init an:
pickerSelection = operators.firstIndex(opValue)
However this will just start an infinite loop as pickerSelection is a #State variable
Anyone have a solution?
This is a method that works. It uses Combine to make an observable one can use to trigger the needed events. Also I see how useful Combine is with SwiftUI
https://stackoverflow.com/a/57519105/810300