Summary
I have filed feedback for this item, but I am curious whether this is an intended change or if there's a better way to address the behavior change - FB11764272.
UITests' XCTest behavior has changed with Xcode 14+ when looking for off-screen elements in a List component.
The Change
Below is how the behavior worked on the last two major versions of Xcode:
Xcode 13: Off-screen elements are visible to the UITest process and will scroll to those elements for interaction. e.g. calling tap() on an off-screen element.
This is how UIKit TableViews work, even for cells that haven't been dequeued yet.
Xcode 14: Off-screen elements are NOT visible to the UITest process by default and will cause a test failure if an interaction is attempted. e.g. calling tap() on an off-screen element.
This change is more in line with how Lazy Stacks worked in SwiftUI for Xcode 13.
The documentation for XCTUIElement's tap() method says the following:
https://developer.apple.com/documentation/xctest/xcuielement/1618666-tap
If the element exists within a scrollable view but is offscreen, XCTest will attempt to scroll the element onscreen before performing the tap.
Also, while an old post (7 years ago), this Developer Forums Post thread #16810 has the following response from an Apple Frameworks Engineer:
https://developer.apple.com/forums/thread/16810
You are not supposed to have to scroll manually. Interacting with an element not currently visible in the scroll view is expected to implicitly first scroll the element to be visible without requiring effort. If that's not working for you, please file a bug report (using the Report Bugs link at the bottom of this page). Thanks!
This was true for SwitUI Lists in Xcode 13 but isn't the case with Xcode 14+ (and it appears to continue to not work for Lazy Stacks within ScrollViews in SwiftUI).
Current Workaround
As my team's entire app is written in SwiftUI and we have an extensive UITest suite, we'll need to find every usage of an interaction API, like tap(), and wrap that with a custom method that first scrolls the entire length of the scroll view (at worst) waiting for the intended element to exist.
This involves a lot of work from a development perspective but also adds a lot of time to run through our extensive UITest suite for an already long-running suite.
Wrap Up
Was this change intentional? If so, what's the suggested way of scrolling to off-screen elements when UI testing a SwiftUI application or is the workaround described above the expected path forward?
Post
Replies
Boosts
Views
Activity
Appearance Issues
I've tried looking through the developer forums and could not find a post that exactly described my situation. I've found many posts that described unexpected onAppear or onDisappear calls, but none that touched on TabView or NavigationView/NavigationStack container views being removed from the view tree.
Versions tested against with the issue:
iOS 14.0 - iOS 16.1
Xcode 13.4.1 - Xcode 14.1
I just filed a feedback (FB11721387), but wanted to post here in case someone has a good workaround or solution.
Summary
When a TabView or NavigationView/NavigationStack is removed from the view tree, there can be unexpected additional calls to appearance
modifiers like onAppear, task, and onDisappear for some of the views.
Example Series of Events:
// ... Assume Already Logged in to Tabbed App
// 1. Tab 2 - OnAppear
// 2. Tab 2 - Task
// 3. Tab 1 - OnDisappear // <- Tab we just left
// 4. Log Out Button Tapped
// 5. Login View - OnAppear
// 6. Login View - Task
// 7. Tab 1 - Task // <- Events 7-9 not expected
// 8. Tab 1 - OnAppear
// 9. Tab 1 - OnDisappear
// 10. Tab 2 - OnDisappear // <- This was expected
TabView Appearance Issues
For TabViews, you must select at least one other tab before changing state to another case that doesn't include the TabView.
When removed from the view tree, all already-viewed, non-currently-viewed tab views will run through all appearance calls again, even
when the state binding that controls the selected tab does not change.
Reproducible Steps
Have an application where the root of the app has some conditional for logged-in and logged-out states, and the logged-in view has a TabView as the root with at least two tabs
Run the app and "Log In"
Select another non-root tab view
Log out (bringing you to the Login View)
Observe the already-viewed, non-currently-selected tab views run through their appearance calls again
NavigationView/NavigationStack Appearance Issues
For NavigationView and NavigationStack (potentially NavigationSplitView as well, but I didn't test this), you must push
at least one other view onto the stack before changing state to another case that doesn't include the Navigation type.
When removed from the view tree, the root view will run through all appearance calls again.
Reproducible Steps
Have an application where the root of the app has some conditional for logged-in and logged-out states, and the logged-in view has a NavigationView/NavigationStack as the root and can push a view onto the stack to log out
Run the app and "Log In"
Push a view onto the stack
Log out (bringing you to the Login View)
Observe the Navigation Root view (Not the Navigation View itself) runs through the appearance calls again
Typical Example
Assume you have an app that has a Login view, and once logged in, you have a tabbed or some other typical navigational structure
as the root of your authenticated app flow. Following any of the above situations will result in additional appearance
callbacks for the affected views when logging out of the application.
Sample Project Pseudocode
struct ContentView: View {
@StateObject
private var viewModel = ViewModel()
var body: some View {
switch viewModel.state {
case .loading:
SplashView()
case .loggedOut:
LoginView()
case .loggedIn:
Authenticated() // Some TabView or other NavigationView/NavigationStack Root
}
}
}
Current Workaround
While not a great workaround that fills me with confidence, using OSSignposts to measure how quickly a view goes from onAppear to onDisappear has shown in my testing that when the bug occurs, it completes the lifecycle within ~15 ms with many completing as few as ~6 ms.
Using this and timing the quickest human interaction for switching tabs to be about 75ms in length, we've decided to implement a debounce plus filter behavior for an onAppear overload that will only perform the provided action if onDisappear isn't called before 30 ms has expired.
I understand this workaround is fragile, considering different devices and resource environments (performance throttling, low battery, etc) can play a factor.
Hoping for a more official answer or better ideas for workarounds. Thanks for any help that can be provided!