Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix incorrect sort order of NavigationStack #169

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Example/AccessibilitySnapshot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
1104A8CF2B580AC500B6715F /* SwiftUITextEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1104A8CE2B580AC500B6715F /* SwiftUITextEntry.swift */; };
1104A8D12B595FF600B6715F /* TextFieldViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1104A8D02B595FF600B6715F /* TextFieldViewController.swift */; };
112909BC2B63E57B00B4EBEB /* TextViewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112909BB2B63E57B00B4EBEB /* TextViewViewController.swift */; };
11C417F22B8E1F6A00850291 /* SwiftUIViewWithNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C417EE2B8E1F6A00850291 /* SwiftUIViewWithNavigationStack.swift */; };
11C417F32B8E1F6A00850291 /* SwiftUIView+EmbedInHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C417EF2B8E1F6A00850291 /* SwiftUIView+EmbedInHostingController.swift */; };
11C417F42B8E1F6A00850291 /* SwiftUIViewWithListWithSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C417F02B8E1F6A00850291 /* SwiftUIViewWithListWithSections.swift */; };
11C417F52B8E1F6A00850291 /* SwiftUIViewWithNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C417F12B8E1F6A00850291 /* SwiftUIViewWithNavigationView.swift */; };
1635CE4E251EAC6700907101 /* SnapshotTestingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1635CE4D251EAC6700907101 /* SnapshotTestingTests.swift */; };
3D04B6D6211558B0006218A4 /* AccessibilityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D04B6D5211558B0006218A4 /* AccessibilityViewController.swift */; };
3D04B6D921155942006218A4 /* LabelAccessibilityPropertiesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D04B6D821155942006218A4 /* LabelAccessibilityPropertiesViewController.swift */; };
Expand Down Expand Up @@ -84,6 +88,10 @@
1104A8CE2B580AC500B6715F /* SwiftUITextEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUITextEntry.swift; sourceTree = "<group>"; };
1104A8D02B595FF600B6715F /* TextFieldViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewController.swift; sourceTree = "<group>"; };
112909BB2B63E57B00B4EBEB /* TextViewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewViewController.swift; sourceTree = "<group>"; };
11C417EE2B8E1F6A00850291 /* SwiftUIViewWithNavigationStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIViewWithNavigationStack.swift; sourceTree = "<group>"; };
11C417EF2B8E1F6A00850291 /* SwiftUIView+EmbedInHostingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftUIView+EmbedInHostingController.swift"; sourceTree = "<group>"; };
11C417F02B8E1F6A00850291 /* SwiftUIViewWithListWithSections.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIViewWithListWithSections.swift; sourceTree = "<group>"; };
11C417F12B8E1F6A00850291 /* SwiftUIViewWithNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIViewWithNavigationView.swift; sourceTree = "<group>"; };
1635CE4D251EAC6700907101 /* SnapshotTestingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestingTests.swift; sourceTree = "<group>"; };
358D84DCD315110A89BD052E /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = "<group>"; };
3A3192D7B9B16BD10FB517A2 /* Pods_SnapshotTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SnapshotTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -192,6 +200,10 @@
1104A8CE2B580AC500B6715F /* SwiftUITextEntry.swift */,
3F8E5DB82535B25000276B32 /* SwiftUIView.swift */,
3FEF854E253846420072611F /* SwiftUIViewWithScrollView.swift */,
11C417EF2B8E1F6A00850291 /* SwiftUIView+EmbedInHostingController.swift */,
11C417F02B8E1F6A00850291 /* SwiftUIViewWithListWithSections.swift */,
11C417EE2B8E1F6A00850291 /* SwiftUIViewWithNavigationStack.swift */,
11C417F12B8E1F6A00850291 /* SwiftUIViewWithNavigationView.swift */,
3D39BFAB223314C1009C3EF4 /* TabBarViewController.swift */,
3D04B6D821155942006218A4 /* LabelAccessibilityPropertiesViewController.swift */,
3D04B6DA21155D92006218A4 /* ButtonAccessibilityTraitsViewController.swift */,
Expand Down Expand Up @@ -629,6 +641,7 @@
3D9894F9213509C8006C16F6 /* DescriptionEdgeCasesViewController.swift in Sources */,
83A295842AC22D9D00DFBE4F /* UserInputLabelsViewController.swift in Sources */,
3D3F2E142263E6B900F7608E /* InvertColorsViewController.swift in Sources */,
11C417F32B8E1F6A00850291 /* SwiftUIView+EmbedInHostingController.swift in Sources */,
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
3D0AC55C222DD70A00B6F1C1 /* UserInterfaceDirectionViewController.swift in Sources */,
3D04B6D6211558B0006218A4 /* AccessibilityViewController.swift in Sources */,
Expand All @@ -640,13 +653,16 @@
3DF464FE220D594E0048D446 /* ElementSelectionViewController.swift in Sources */,
3DC488372212A7D4006D1E15 /* ModalAccessibilityViewController.swift in Sources */,
3DF46502220D7F9B0048D446 /* ElementOrderViewController.swift in Sources */,
11C417F22B8E1F6A00850291 /* SwiftUIViewWithNavigationStack.swift in Sources */,
112909BC2B63E57B00B4EBEB /* TextViewViewController.swift in Sources */,
3DDE7FF624C6D6BF00999ABA /* AccessibilityCustomActionsViewController.swift in Sources */,
3DBAC28F2242E7C700EF4D0A /* ListContainerViewController.swift in Sources */,
3DBEAA5B2222953E00FAE61D /* SwitchControlViewController.swift in Sources */,
11C417F52B8E1F6A00850291 /* SwiftUIViewWithNavigationView.swift in Sources */,
3DBAC2912242F9B200EF4D0A /* LandmarkContainerViewController.swift in Sources */,
1104A8CF2B580AC500B6715F /* SwiftUITextEntry.swift in Sources */,
3D39BFAC223314C1009C3EF4 /* TabBarViewController.swift in Sources */,
11C417F42B8E1F6A00850291 /* SwiftUIViewWithListWithSections.swift in Sources */,
3D04B6DB21155D92006218A4 /* ButtonAccessibilityTraitsViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know y'all just got rid of this, but I brought it back because the navigation buttons were not being rendered when using only the SwiftUI views.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, they weren't being rendered at all? The only thing that should be different between these is how the view is sized (setting the frame vs. bounds). Might affect the safe area insets, but everything should still be rendered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I misspoke. The button is getting rendered but it is not included as an accessibility element

testNavigationStack 393x852-16-4-3x

Changing this code from bounds to frame resolves the issue: https://github.com/cashapp/AccessibilitySnapshot/blob/master/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting%2BSwiftUI.swift#L68

// Copyright 2021 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#if swift(>=5.1) && canImport(SwiftUI)

import SwiftUI
import UIKit

extension View {

// This method should be removed once we support snapshotting native SwiftUI views.
// See cashapp/AccessibilitySnapshot#37.
func embedInHostingController() -> UIViewController {
return UIHostingController(rootView: self)
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// Copyright 2023 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//


#if swift(>=5.1) && canImport(SwiftUI)

import SwiftUI

@available(iOS 15.0, *)
struct SwiftUIViewWithListWithSections: View {
@State var groupedItems: [(String, [String])] = [
("Section 1", ["Item 1", "Item 2", "Item 3"]),
("Section 2", ["Item 1", "Item 2", "Item 3"]),
("Section 3", ["Item 1", "Item 2", "Item 3"]),
]

var body: some View {
List {
ForEach(groupedItems, id: \.0) { groupedItem in
Section(groupedItem.0) {
ForEach(groupedItem.1, id: \.self) { item in
Text(item)
}
}
}
}
}
}

@available(iOS 15.0, *)
struct SwiftUIViewWithListWithSections_Previews: PreviewProvider {
static var previews: some View {
SwiftUIViewWithListWithSections()
.previewLayout(.sizeThatFits)
}
}

#endif
49 changes: 49 additions & 0 deletions Example/AccessibilitySnapshot/SwiftUIViewWithNavigationStack.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// Copyright 2023 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//


#if swift(>=5.1) && canImport(SwiftUI)

import SwiftUI

@available(iOS 16.0, *)
struct SwiftUIViewWithNavigationStack: View {
var body: some View {
NavigationStack {
Text("Text inside a NavigationStack")
.navigationTitle("Navigation Stack")
.toolbar {
ToolbarItem {
Button {
// no-op
} label: {
Text("Add")
}
}
}
}
}
}

@available(iOS 16.0, *)
struct SwiftUIViewWithNavigationStack_Previews: PreviewProvider {
static var previews: some View {
SwiftUIViewWithNavigationStack()
.previewLayout(.sizeThatFits)
}
}

#endif
49 changes: 49 additions & 0 deletions Example/AccessibilitySnapshot/SwiftUIViewWithNavigationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// Copyright 2023 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//


#if swift(>=5.1) && canImport(SwiftUI)

import SwiftUI

@available(iOS 14.0, *)
struct SwiftUIViewWithNavigationView: View {
var body: some View {
NavigationView {
Text("Text inside a NavigationView")
.navigationTitle("Navigation View")
.toolbar {
ToolbarItem {
Button {
// no-op
} label: {
Text("Add")
}
}
}
}
}
}

@available(iOS 16.0, *)
struct SwiftUIViewWithNavigationView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIViewWithNavigationView()
.previewLayout(.sizeThatFits)
}
}

#endif
39 changes: 39 additions & 0 deletions Example/SnapshotTests/SnapshotTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,45 @@ final class SnapshotTestingTests: XCTestCase {
)
}

@available(iOS 16.0, *)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember correctly this doesn't do quite enough to make the tests pass and I'll need to add an XCTSkip.

func testNavigationStack() {
let viewController = SwiftUIViewWithNavigationStack().embedInHostingController()

viewController.view.frame = UIScreen.main.bounds

assertSnapshot(
matching: viewController,
as: .accessibilityImage,
named: nameForDevice()
)
}

@available(iOS 14.0, *)
func testNavigationView() {
let viewController = SwiftUIViewWithNavigationView().embedInHostingController()

viewController.view.frame = UIScreen.main.bounds

assertSnapshot(
matching: viewController,
as: .accessibilityImage,
named: nameForDevice()
)
}

@available(iOS 15.0, *)
func testListWithSections() {
let viewController = SwiftUIViewWithListWithSections().embedInHostingController()

viewController.view.frame = UIScreen.main.bounds

assertSnapshot(
matching: viewController,
as: .accessibilityImage,
named: nameForDevice()
)
}

// MARK: - Private Methods

private func nameForDevice(baseName: String? = nil) -> String {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,28 @@ private extension NSObject {
)
)
}

let explicitOrderingExceptionList = [
"UILayoutContainerView",
"UpdateCoalescingCollectionView",
]
Comment on lines +608 to +611
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this fixes the specific bug we have a repro case for, I suspect it's hiding a larger issue around how we're matching VoiceOver's treatment of accessibility containers. If we recreate the same accessibility hierarchy of a UILayoutContainerView or UpdateCoalescingCollectionView do we get the same incorrect results? Looks like they might be using containers of containers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Digging into this to try to understand it better.

Copy link
Contributor Author

@DavidBrunow DavidBrunow Dec 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update with what I've found so far:

Both the NavigationView and NavigationStack have a UILayoutContainerView in their view hierarchy -- in general both view hierarchies look very similar. The key difference between the two is that the UILayoutContainerView in the NavigationView has shouldGroupAccessibilityChildren set to true while in the NavigationStack it is set to false.

This is key to the problem because the shouldGroupAccessiblityChildren property is what is used to determine whether a group of accessibility elements has explicitlyOrdered set to false: https://github.com/cashapp/AccessibilitySnapshot/blob/master/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilityHierarchyParser.swift#L631

To me this points to what you mentioned: a larger issue around how the library is matching VoiceOver's treatment of accessibility containers. In specific I think we need improvements to how explicitlyOrdered is determined, or maybe how it is used to create the sorted elements, but I don't have suggestions at this time.


let explicitlyOrdered = accessibilityElements.contains {
explicitOrderingExceptionList.contains("\(type(of: $0))") == false
}
let shouldBeExplicitlyOrdered = accessibilityHierarchyOfElements.contains { node in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is another approach to determining whether a group should be explicitly ordered. I need to think it through a bit more but I think this matches how VoiceOver works since the ordering is determined by the order of the accessibilityElements array.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NickEntin could I get your thoughts on this approach?

switch node {
case .element:
// Only set explicitly ordered if an element node is a direct descendent of this group.
return true
case .group:
return false
}
}

recursiveAccessibilityHierarchy.append(.group(
accessibilityHierarchyOfElements,
explicitlyOrdered: true,
explicitlyOrdered: shouldBeExplicitlyOrdered,
frameOverrideProvider: (overridesElementFrame(with: contextProvider) ? self : nil)
))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
)
.pullback { (view: Value) in
let hostingController = UIHostingController(rootView: view)
hostingController.view.bounds.size = size ?? hostingController.sizeThatFits(in: .zero)
hostingController.view.frame.size = size ?? hostingController.sizeThatFits(in: .zero)
return hostingController
}
}
Expand Down
Loading