diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/EmbeddedFeatureFormView.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/EmbeddedFeatureFormView.swift index b6d045ef47..07523346a8 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/EmbeddedFeatureFormView.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/EmbeddedFeatureFormView.swift @@ -26,17 +26,15 @@ struct EmbeddedFeatureFormView: View { } var body: some View { - ScrollViewReader { scrollViewProxy in - ScrollView { - VStack(alignment: .leading) { - ForEach(embeddedFeatureFormViewModel.visibleElements, id: \.self) { element in - makeElement(element) - } - } + ScrollViewReader { scrollView in + List(embeddedFeatureFormViewModel.visibleElements, id: \.self) { element in + makeElement(element) } .onChange(of: embeddedFeatureFormViewModel.focusedElement) { if let focusedElement = embeddedFeatureFormViewModel.focusedElement { - withAnimation { scrollViewProxy.scrollTo(focusedElement, anchor: .top) } + // Navigation bars will unfortunately cover or obscure + // section headers. See FB19740517. + withAnimation { scrollView.scrollTo(focusedElement, anchor: .top) } } } .onTitleChange(of: embeddedFeatureFormViewModel.featureForm) { newTitle in @@ -49,7 +47,6 @@ struct EmbeddedFeatureFormView: View { .scrollDismissesKeyboard(.immediately) #endif .environment(embeddedFeatureFormViewModel) - .padding(.horizontal) .preference( key: PresentedFeatureFormPreferenceKey.self, value: .init(object: embeddedFeatureFormViewModel.featureForm) @@ -62,11 +59,19 @@ extension EmbeddedFeatureFormView { /// Makes UI for a form element. /// - Parameter element: The element to generate UI for. @ViewBuilder func makeElement(_ element: FormElement) -> some View { - switch element { - case let element as GroupFormElement: - GroupFormElementView(element: element) { internalMakeElement($0) } - default: - internalMakeElement(element) + Section { + switch element { + case let element as GroupFormElement: + GroupFormElementView(element: element) { internalMakeElement($0) } + default: + internalMakeElement(element) + } + } header: { + FormElementHeader(element: element) + .textCase(.none) + } footer: { + FormElementFooter(element: element) + .textCase(.none) } } @@ -94,8 +99,7 @@ extension EmbeddedFeatureFormView { /// - Parameter element: The element to generate UI for. @ViewBuilder func makeFieldElement(_ element: FieldFormElement) -> some View { if !(element.input is UnsupportedFormInput) { - FormElementWrapper(element: element) - Divider() + FieldFormElementView(element: element) } } @@ -103,13 +107,11 @@ extension EmbeddedFeatureFormView { /// - Parameter element: The element to generate UI for. @ViewBuilder func makeTextElement(_ element: TextFormElement) -> some View { TextFormElementView(element: element) - Divider() } /// Makes UI for a utility associations element including a divider beneath it. /// - Parameter element: The element to generate UI for. @ViewBuilder func makeUtilityAssociationsFormElement(_ element: UtilityAssociationsFormElement) -> some View { - FormElementWrapper(element: element) - Divider() + FeatureFormView.UtilityAssociationsFormElementView(element: element) } } diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FeatureFormGroupedContentView.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FeatureFormGroupedContentView.swift deleted file mode 100644 index 38253c724f..0000000000 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FeatureFormGroupedContentView.swift +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2025 Esri -// -// 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 -// -// https://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. - -import SwiftUI - -/// A view to display grouped content together within a ScrollView. -struct FeatureFormGroupedContentView: View { - let content: [Content] - - var body: some View { - VStack(alignment: .leading) { - ForEach(content.enumerated().map({ ($0.offset, $0.element) }), id: \.0) { (offset, content) in - content - if offset + 1 != self.content.count { - Divider() - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .formInputStyle(isTappable: false) - } -} - -#Preview { - ScrollView { - FeatureFormGroupedContentView(content: [ - Button { } label: { - Text(verbatim: "A Button") - Spacer() - Image(systemName: "chevron.right") - } - ]) - - FeatureFormGroupedContentView(content: [ - Text(verbatim: "Text 1"), Text(verbatim: "Text 2") - ]) - - FeatureFormGroupedContentView(content: [ - NavigationLink("Navigation Link 1", value: 1), - NavigationLink("Navigation Link 2", value: 2), - NavigationLink("Navigation Link 3", value: 3) - ]) - } -} diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FieldFormElementView.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FieldFormElementView.swift index d7723b8075..790dfdd0f5 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FieldFormElementView.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FieldFormElementView.swift @@ -15,6 +15,7 @@ import ArcGIS import SwiftUI +/// A view which creates the correct input view based on the field form element's input type and editable state. struct FieldFormElementView: View { /// A Boolean value indicating whether the input is editable. @State private var isEditable = false diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementFooter.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementFooter.swift index bc1c3acbd6..4ad5510583 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementFooter.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementFooter.swift @@ -36,6 +36,8 @@ struct FormElementFooter: View { case let element as UtilityAssociationsFormElement: UtilityAssociationsFormElementFooter(element: element) default: + // GroupFormElement's description is shown in the DisclosureGroup's + // label. EmptyView() } } diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementHeader.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementHeader.swift index ae4b6c5a15..90aa7bd23e 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementHeader.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementHeader.swift @@ -27,7 +27,6 @@ struct FormElementHeader: View { titleTextForElement .font(.subheadline) .foregroundStyle(.secondary) - Spacer() } .padding(.top, formElementPadding) } @@ -37,10 +36,8 @@ struct FormElementHeader: View { switch element { case let element as FieldFormElement: FieldFormElementTitle(element: element) - case let element as UtilityAssociationsFormElement: - Text(element.label) default: - EmptyView() + Text(element.label) } } } @@ -63,6 +60,10 @@ extension FormElementHeader { .onIsRequiredChange(of: element) { newIsRequired in isRequired = newIsRequired } + Spacer() + if !isEditable { + Image(systemName: "pencil.slash") + } } } } diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementWrapper.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementWrapper.swift deleted file mode 100644 index 3c087c0e00..0000000000 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/FormElementWrapper/FormElementWrapper.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2024 Esri -// -// 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 -// -// https://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. - -import ArcGIS -import SwiftUI - -/// A view which wraps the creation of a view for the underlying field form element. -/// -/// This view injects a header and footer. It also monitors whether a field form element is editable and -/// chooses the correct input view based on the input type. -struct FormElementWrapper: View { - /// The wrapped form element. - let element: FormElement - - var body: some View { - VStack(alignment: .leading) { - FormElementHeader(element: element) - switch element { - case let element as FieldFormElement: - FieldFormElementView(element: element) - case let element as UtilityAssociationsFormElement: - FeatureFormView.UtilityAssociationsFormElementView(element: element) - default: - EmptyView() - } - FormElementFooter(element: element) - } - } -} diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/GroupFormElementView.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/GroupFormElementView.swift index 8e0b563ab2..405964cfa3 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/GroupFormElementView.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/GroupFormElementView.swift @@ -30,11 +30,15 @@ struct GroupFormElementView: View where Content: View { var body: some View { DisclosureGroup(isExpanded: $isExpanded) { ForEach(visibleElements, id: \.self) { element in - viewCreator(element) - .padding(.leading, 16) + VStack(alignment: .leading) { + FormElementHeader(element: element) + viewCreator(element) + .formInputStyle(isTappable: true) + FormElementFooter(element: element) + } } } label: { - Header(element: element) + Label(element: element) .multilineTextAlignment(.leading) .tint(.primary) } @@ -67,21 +71,15 @@ struct GroupFormElementView: View where Content: View { extension GroupFormElementView { /// A view displaying a label and description of a `GroupFormElement`. - struct Header: View { + struct Label: View { let element: GroupFormElement var body: some View { - VStack(alignment: .leading) { - if !element.label.isEmpty { - Text(element.label) - .accessibilityIdentifier("\(element.label)") - } - if !element.description.isEmpty { - Text(element.description) - .accessibilityIdentifier("\(element.label) Description") - .font(.caption2) - .foregroundStyle(.secondary) - } + if !element.description.isEmpty { + Text(element.description) + .accessibilityIdentifier("\(element.label) Description") + .font(.caption2) + .foregroundStyle(.secondary) } } } diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/UtilityAssociationsFormElementView.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/UtilityAssociationsFormElementView.swift index 9e453ecc8a..d63b11fe6c 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/UtilityAssociationsFormElementView.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/FormElements/UtilityAssociationsFormElementView.swift @@ -37,25 +37,21 @@ extension FeatureFormView { switch associationsFilterResultsModel.result { case .success(let results): if results.isEmpty { - FeatureFormGroupedContentView( - content: [Text.noAssociations] - ) + Text.noAssociations } else { - FeatureFormGroupedContentView(content: results.map { + ForEach(results.indices) { index in UtilityAssociationsFilterResultListRowView( associationsFilterResultsModel: associationsFilterResultsModel, element: element, filterTitle: $0.filter.title ) .environment(embeddedFeatureFormViewModel) - }) + } } case .failure(let error): - FeatureFormGroupedContentView(content: [ - Text.errorFetchingFilterResults(error) - ]) - case .none: - FeatureFormGroupedContentView(content: [ProgressView()]) + Text.errorFetchingFilterResults(error) + case nil: + ProgressView() } } .onChange(of: embeddedFeatureFormViewModel.hasEdits) { diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ComboBoxInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ComboBoxInput.swift index c53756e2fc..ee7464a0f2 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ComboBoxInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ComboBoxInput.swift @@ -84,6 +84,11 @@ struct ComboBoxInput: View { .accessibilityIdentifier("\(element.label) Combo Box Value") .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(!selectedValue.isNoValue ? .primary : .secondary) + .contentShape(.rect) + .onTapGesture { + embeddedFeatureFormViewModel.focusedElement = element + isPresented = true + } if let _ = selectedValue.codedValue, !isRequired { // Only show clear button if we have a value // and we're not required. (i.e., Don't show clear if @@ -101,7 +106,6 @@ struct ComboBoxInput: View { .foregroundStyle(.secondary) } } - .formInputStyle(isTappable: true) .onIsRequiredChange(of: element) { newIsRequired in isRequired = newIsRequired } @@ -116,10 +120,6 @@ struct ComboBoxInput: View { selectedValue = .noValue } } - .onTapGesture { - embeddedFeatureFormViewModel.focusedElement = element - isPresented = true - } .sheet(isPresented: $isPresented) { makePicker() } diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/DateTimeInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/DateTimeInput.swift index 5f27e1f946..0578f58577 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/DateTimeInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/DateTimeInput.swift @@ -86,6 +86,23 @@ struct DateTimeInput: View { formattedDate .accessibilityIdentifier("\(element.label) Value") .foregroundStyle(displayColor) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .onTapGesture { + withAnimation { + if date == nil { + if dateRange.contains(.now) { + date = .now + } else if let min = input.min { + date = min + } else if let max = input.max { + date = max + } + } + isEditing.toggle() + embeddedFeatureFormViewModel.focusedElement = isEditing ? element : nil + } + } Spacer() @@ -107,23 +124,6 @@ struct DateTimeInput: View { } } } - .formInputStyle(isTappable: true) - .frame(maxWidth: .infinity) - .onTapGesture { - withAnimation { - if date == nil { - if dateRange.contains(.now) { - date = .now - } else if let min = input.min { - date = min - } else if let max = input.max { - date = max - } - } - isEditing.toggle() - embeddedFeatureFormViewModel.focusedElement = isEditing ? element : nil - } - } } /// The system formatted version of the element's current date. diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/RadioButtonsInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/RadioButtonsInput.swift index bdaec3b391..d228e7ff8f 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/RadioButtonsInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/RadioButtonsInput.swift @@ -79,11 +79,6 @@ struct RadioButtonsInput: View { } } } - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(uiColor: .tertiarySystemFill)) - ) - .frame(maxWidth: .infinity, alignment: .leading) .onAppear { if let selectedValue = element.codedValues.first(where: { $0.name == element.formattedValue diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ReadOnlyInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ReadOnlyInput.swift index 9a895efc84..812f28475c 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ReadOnlyInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/ReadOnlyInput.swift @@ -41,7 +41,6 @@ struct ReadOnlyInput: View { .fixedSize(horizontal: false, vertical: true) .id(id) .lineLimit(element.isMultiline ? nil : 1) - .padding(.horizontal, 10) .padding(.vertical, 5) .textSelection(.enabled) .onValueChange(of: element) { _, _ in diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/SwitchInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/SwitchInput.swift index cbb5b21083..e27887019c 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/SwitchInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/SwitchInput.swift @@ -67,7 +67,6 @@ struct SwitchInput: View { Text(isOn ? input.onValue.name : input.offValue.name) } .accessibilityIdentifier("\(element.label) Switch") - .formInputStyle(isTappable: false) .onAppear { if element.formattedValue.isEmpty { fallbackToComboBox = true diff --git a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/TextInput.swift b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/TextInput.swift index 360fc7e891..c5b208a5ca 100644 --- a/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/TextInput.swift +++ b/Sources/ArcGISToolkit/Components/FeatureFormView/Subviews/Inputs/TextInput.swift @@ -76,17 +76,13 @@ struct TextInput: View { element.convertAndUpdateValue(text) embeddedFeatureFormViewModel.evaluateExpressions() } - .onTapGesture { - if element.isMultiline { - fullScreenTextInputIsPresented = true - } - } #if !os(visionOS) .sheet(isPresented: $scannerIsPresented) { CodeScanner(code: $text, isPresented: $scannerIsPresented) } #endif - .onValueChange(of: element, when: !element.isMultiline || !fullScreenTextInputIsPresented) { _, newFormattedValue in + .onValueChange(of: element) { _, newFormattedValue in + guard newFormattedValue != text else { return } text = newFormattedValue } } @@ -111,6 +107,12 @@ private extension TextInput { #endif } .frame(minHeight: 100, alignment: .top) + .contentShape(.rect) + .onTapGesture { + if element.isMultiline { + fullScreenTextInputIsPresented = true + } + } } else { TextField( element.label, @@ -190,7 +192,6 @@ private extension TextInput { } #endif } - .formInputStyle(isTappable: true) } /// The keyboard type to use depending on where the input is numeric and decimal. @@ -285,39 +286,3 @@ private extension TextInput { element.input is BarcodeScannerFormInput } } - -private extension View { - /// Wraps `onValueChange(of:action:)` with an additional boolean property that when false will - /// not monitor value changes. - /// - Parameters: - /// - element: The form element to watch for changes on. - /// - when: The boolean value which disables monitoring. When `true` changes will be monitored. - /// - action: The action which watches for changes. - /// - Returns: The modified view. - func onValueChange(of element: FieldFormElement, when: Bool, action: @escaping (_ newValue: Any?, _ newFormattedValue: String) -> Void) -> some View { - modifier( - ConditionalChangeOfModifier(element: element, condition: when) { newValue, newFormattedValue in - action(newValue, newFormattedValue) - } - ) - } -} - -private struct ConditionalChangeOfModifier: ViewModifier { - let element: FieldFormElement - - let condition: Bool - - let action: (_ newValue: Any?, _ newFormattedValue: String) -> Void - - func body(content: Content) -> some View { - if condition { - content - .onValueChange(of: element) { newValue, newFormattedValue in - action(newValue, newFormattedValue) - } - } else { - content - } - } -} diff --git a/Test Runner/UI Tests/FeatureFormViewTests.swift b/Test Runner/UI Tests/FeatureFormViewTests.swift index a0d3511515..87a2bfbb83 100644 --- a/Test Runner/UI Tests/FeatureFormViewTests.swift +++ b/Test Runner/UI Tests/FeatureFormViewTests.swift @@ -113,6 +113,8 @@ final class FeatureFormViewTests: XCTestCase { /// Test case 1.1: unfocused and focused state, no value func testCase_1_1() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let characterIndicator = app.staticTexts["Single Line No Value, Placeholder or Description Character Indicator"] let fieldTitle = app.staticTexts["Single Line No Value, Placeholder or Description"] @@ -123,6 +125,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(fieldTitle, direction: .up) + XCTAssertTrue( fieldTitle.exists, "The field title doesn't exist." @@ -171,6 +175,8 @@ final class FeatureFormViewTests: XCTestCase { /// Test case 1.2: focused and unfocused state, with value (populated) func testCase_1_2() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let characterIndicator = app.staticTexts["Single Line No Value, Placeholder or Description Character Indicator"] let clearButton = app.buttons["Single Line No Value, Placeholder or Description Clear Button"] @@ -183,6 +189,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(textField, direction: .up) + textField.tap() app.typeText("Sample text") @@ -217,12 +225,15 @@ final class FeatureFormViewTests: XCTestCase { "The clear button doesn't exist." ) -#if targetEnvironment(macCatalyst) +#if targetEnvironment(macCatalyst) || os(visionOS) app.typeText("\r") #else returnButton.tap() #endif + // Scroll slightly up to expose section header. FB19740517 + app.scrollToElement(fieldTitle, direction: .down, maxSwipes: 1, velocity: .slow) + XCTAssertTrue( fieldTitle.isHittable, "The title isn't hittable." @@ -238,6 +249,8 @@ final class FeatureFormViewTests: XCTestCase { "The clear button isn't hittable." ) + app.scrollToElement(textField, direction: .up, maxSwipes: 1, velocity: .slow) + XCTAssertTrue( textField.isHittable, "The text field isn't hittable." @@ -245,7 +258,9 @@ final class FeatureFormViewTests: XCTestCase { } /// Test case 1.3: unfocused and focused state, with error value (> 256 chars) - func testCase_1_3() throws { + func testCase_1_3() async throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let characterIndicator = app.staticTexts["Single Line No Value, Placeholder or Description Character Indicator"] let clearButton = app.buttons["Single Line No Value, Placeholder or Description Clear Button"] @@ -258,6 +273,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(textField, direction: .up) + textField.tap() app.typeText(.loremIpsum257) @@ -282,9 +299,14 @@ final class FeatureFormViewTests: XCTestCase { "The character count doesn't exist." ) - XCTAssertEqual( - characterIndicator.label, - "257" + await fulfillment( + of: [ + expectation( + for: NSPredicate(format: "label == \"257\""), + evaluatedWith: characterIndicator + ) + ], + timeout: 10.0 ) XCTAssertTrue( @@ -292,7 +314,7 @@ final class FeatureFormViewTests: XCTestCase { "The clear button doesn't exist." ) -#if targetEnvironment(macCatalyst) +#if targetEnvironment(macCatalyst) || os(visionOS) app.typeText("\r") #else returnButton.tap() @@ -329,7 +351,7 @@ final class FeatureFormViewTests: XCTestCase { ) } - func testCase_1_4() { + func testCase_1_4() async { let app = XCUIApplication() let footer = app.staticTexts["numbers Footer"] let formTitle = app.staticTexts["Domain"] @@ -361,11 +383,15 @@ final class FeatureFormViewTests: XCTestCase { textField.doubleTap() textField.typeText("3") - expectation( - for: NSPredicate(format: "label == \"Range domain 2-5\""), - evaluatedWith: footer + await fulfillment( + of: [ + expectation( + for: NSPredicate(format: "label == \"Range domain 2-5\""), + evaluatedWith: footer + ) + ], + timeout: 10 ) - waitForExpectations(timeout: 10, handler: nil) // Highlight/select the current value and replace it textField.doubleTap() @@ -393,6 +419,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(fieldValue, direction: .up) + if fieldValue.label != "No Value" { clearButton.tap() } @@ -434,6 +462,8 @@ final class FeatureFormViewTests: XCTestCase { "The now button isn't hittable." ) + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertEqual( footer.label, "Date Entry is Required" @@ -441,7 +471,9 @@ final class FeatureFormViewTests: XCTestCase { } /// Test case 2.2: Focused and unfocused state, with value (populated) - func testCase_2_2() { + func testCase_2_2() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let datePicker = app.datePickers["Launch Date and Time for Apollo 11 Date Picker"] let fieldTitle = app.staticTexts["Launch Date and Time for Apollo 11"] @@ -455,6 +487,9 @@ final class FeatureFormViewTests: XCTestCase { fieldValue.tap() + // Scroll slightly up to expose section header. FB19740517 + app.scrollToElement(fieldTitle, direction: .down, maxSwipes: 1, velocity: .slow) + XCTAssertTrue( fieldTitle.isHittable, "The field title isn't hittable." @@ -471,6 +506,8 @@ final class FeatureFormViewTests: XCTestCase { localDate?.formatted() ) + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertEqual( footer.label, "Enter the launch date and time (July 16, 1969 13:32 UTC)" @@ -481,6 +518,8 @@ final class FeatureFormViewTests: XCTestCase { "The date picker doesn't exist." ) + app.scrollToElement(nowButton, direction: .down, velocity: .slow) + XCTAssertTrue( nowButton.isHittable, "The now button isn't hittable." @@ -551,7 +590,9 @@ final class FeatureFormViewTests: XCTestCase { } /// Test case 2.4: Maximum date - func testCase_2_4() { + func testCase_2_4() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let clearButton = app.buttons["Launch Date Time End Clear Button"] let fieldValue = app.staticTexts["Launch Date Time End Value"] @@ -562,17 +603,23 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(fieldValue, direction: .up) + if fieldValue.label != "No Value" { clearButton.tap() } fieldValue.tap() + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertTrue( footer.exists, "The footer doesn't exist." ) + app.scrollToElement(nowButton, direction: .down, velocity: .slow) + XCTAssertTrue( nowButton.waitForExistence(timeout: 2.5), "The Now button doesn't exist." @@ -592,6 +639,8 @@ final class FeatureFormViewTests: XCTestCase { fieldValue.tap() + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertEqual( footer.label, "End date and Time 7/27/1969 12:00:00 AM" @@ -603,6 +652,8 @@ final class FeatureFormViewTests: XCTestCase { ) ) + app.scrollToElement(fieldValue, direction: .down, velocity: .slow) + XCTAssertEqual( fieldValue.label, localDate?.formatted() @@ -610,7 +661,9 @@ final class FeatureFormViewTests: XCTestCase { } /// Test case 2.5: Minimum date - func testCase_2_5() { + func testCase_2_5() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let datePicker = app.datePickers["start and end date time Date Picker"] let fieldValue = app.staticTexts["start and end date time Value"] @@ -623,8 +676,12 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(fieldValue, direction: .up) + fieldValue.tap() + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertTrue( footer.exists, "The footer doesn't exist." @@ -639,8 +696,12 @@ final class FeatureFormViewTests: XCTestCase { """ ) + app.scrollToElement(nowButton, direction: .down, velocity: .slow) + nowButton.tap() + app.scrollToElement(julyFirstButton, direction: .up, velocity: .slow) + julyFirstButton.tap() let localDate = Calendar.current.date( @@ -649,6 +710,8 @@ final class FeatureFormViewTests: XCTestCase { ) ) + app.scrollToElement(fieldValue, direction: .down, velocity: .slow) + XCTAssertEqual( fieldValue.label, localDate?.formatted() @@ -881,6 +944,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(footer, direction: .up, velocity: .slow) + XCTAssertTrue( fieldTitle.exists, "The field title doesn't exist." @@ -947,17 +1012,11 @@ final class FeatureFormViewTests: XCTestCase { "The field title doesn't exist." ) - if #available(iOS 18.0, *) { - XCTAssertFalse( - fieldValue.exists, - "The field value exists but it should not because it is empty." - ) - } else { - XCTAssertEqual( - fieldValue.label, - "" - ) - } + XCTAssertEqual( + fieldValue.label, + "", + "The field value was not empty as expected." + ) optionsButton.tap() @@ -1004,6 +1063,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(fieldTitle, direction: .up) + XCTAssertTrue( fieldTitle.exists, "The field title doesn't exist." @@ -1098,12 +1159,16 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(field1, direction: .up) + // Verify the Radio Button fallback to Combo Box was successful. XCTAssertTrue( field1.exists, "The combo box doesn't exist." ) + app.scrollToElement(noValueEnabledRadioButton, direction: .up) + // Verify the radio buttons are shown even when the value option is enabled. XCTAssertTrue( noValueEnabledRadioButton.exists, @@ -1134,12 +1199,16 @@ final class FeatureFormViewTests: XCTestCase { "The field title isn't hittable." ) +#if targetEnvironment(macCatalyst) + XCTExpectFailure("The switch cannot be found on Mac Catalyst.") +#endif + XCTAssertEqual( switchView.label, "2" ) - switchView.tap() + switchView.tapSwitch() XCTAssertEqual( switchView.label, @@ -1162,6 +1231,10 @@ final class FeatureFormViewTests: XCTestCase { "The field title isn't hittable." ) +#if targetEnvironment(macCatalyst) + XCTExpectFailure("The switch cannot be found on Mac Catalyst.") +#endif + XCTAssertEqual( switchView.label, "1" @@ -1172,7 +1245,7 @@ final class FeatureFormViewTests: XCTestCase { "The switch isn't hittable." ) - switchView.tap() + switchView.tapSwitch() XCTAssertEqual( switchView.label, @@ -1204,17 +1277,15 @@ final class FeatureFormViewTests: XCTestCase { /// Test case 6.1: Test initially expanded and collapsed func testCase_6_1() { let app = XCUIApplication() + let collapsedGroup = app.staticTexts["Group with Multiple Form Elements 2"] let collapsedGroupFirstElement = app.staticTexts["Single Line Text"] + let expandedGroup = app.staticTexts["Group with Multiple Form Elements"] let expandedGroupFirstElement = app.staticTexts["MultiLine Text"] let formTitle = app.staticTexts["group_formelement_UI_not_editable"] #if targetEnvironment(macCatalyst) - let collapsedGroup = app.disclosureTriangles["Group with Multiple Form Elements 2"] - let expandedGroup = app.disclosureTriangles["Group with Multiple Form Elements"] let expandedGroupDescription = app.disclosureTriangles["Group with Multiple Form Elements Description"] #else - let collapsedGroup = app.staticTexts["Group with Multiple Form Elements 2"] - let expandedGroup = app.staticTexts["Group with Multiple Form Elements"] let expandedGroupDescription = app.staticTexts["Group with Multiple Form Elements Description"] #endif @@ -1242,6 +1313,8 @@ final class FeatureFormViewTests: XCTestCase { "The first group element doesn't exist." ) + app.scrollToElement(collapsedGroup, direction: .up) + XCTAssertTrue( collapsedGroup.exists, "The collapsed group header doesn't exist." @@ -1255,7 +1328,9 @@ final class FeatureFormViewTests: XCTestCase { } /// Test case 6.2: Test visibility of empty group - func testCase_6_2() { + func testCase_6_2() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let formTitle = app.staticTexts["group_formelement_UI_not_editable"] let groupElement = app.staticTexts["single line text 3"] @@ -1272,11 +1347,15 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(hiddenElementsGroup, direction: .up) + XCTAssertTrue( hiddenElementsGroup.exists, "The group header doesn't exist." ) + app.scrollToElement(hiddenElementsGroupDescription, direction: .up) + XCTAssertTrue( hiddenElementsGroupDescription.exists, "The expanded group's description doesn't exist." @@ -1293,6 +1372,8 @@ final class FeatureFormViewTests: XCTestCase { "The first group element exists but should be hidden." ) + app.scrollToElement(showElementsButton, direction: .down) + // Confirm the option to show the elements exists. XCTAssertTrue( showElementsButton.exists, @@ -1301,6 +1382,8 @@ final class FeatureFormViewTests: XCTestCase { showElementsButton.tap() + app.scrollToElement(groupElement, direction: .up) + // Confirm the first element of the conditional group doesn't exist. XCTAssertTrue( groupElement.exists, @@ -1310,6 +1393,8 @@ final class FeatureFormViewTests: XCTestCase { /// Test case 7.1: Test read only elements func testCase_7_1() throws { + try skipForCatalystScrollBehavior() + let app = XCUIApplication() let formTitle = app.staticTexts["Test Case 7.1 - Read only elements"] let elementsAreEditableSwitch = app.switches["Elements are editable Switch"] @@ -1340,17 +1425,26 @@ final class FeatureFormViewTests: XCTestCase { XCTAssertTrue(radioButtonsReadOnlyInput.exists) + app.scrollToElement(dateReadOnlyInput, direction: .up) + XCTAssertTrue(dateReadOnlyInput.exists) + app.scrollToElement(shortTextReadOnlyInput, direction: .up) + XCTAssertTrue(shortTextReadOnlyInput.exists) + app.scrollToElement(longTextReadOnlyInput, direction: .up) + XCTAssertTrue(longTextReadOnlyInput.exists) - elementsAreEditableSwitch.tap() + // Scroll slightly up to expose section header. FB19740517 + app.scrollToElement(elementsAreEditableSwitch, direction: .down) + + elementsAreEditableSwitch.tapSwitch() XCTAssertTrue(elementInTheGroupIsEditableSwitch.exists) - elementInTheGroupIsEditableSwitch.tap() + elementInTheGroupIsEditableSwitch.tapSwitch() XCTAssertTrue(comboBox.exists) @@ -1358,8 +1452,12 @@ final class FeatureFormViewTests: XCTestCase { XCTAssertTrue(dateInput.exists) + app.scrollToElement(shortTextTextInput, direction: .up) + XCTAssertTrue(shortTextTextInput.exists) + app.scrollToElement(longTextTextInputPreview, direction: .up) + XCTAssertTrue(longTextTextInputPreview.exists) } @@ -1377,7 +1475,7 @@ final class FeatureFormViewTests: XCTestCase { assertFormOpened(titleElement: formTitle) XCTAssertTrue(attachmentElementTitle.waitForExistence(timeout: 10)) - XCTAssertTrue(placeholderImage.exists) + XCTAssertTrue(placeholderImage.waitForExistence(timeout: 10)) XCTAssertTrue(attachmentName.exists) XCTAssertTrue(sizeLabel.exists) XCTAssertTrue(downloadIcon.exists) @@ -1385,7 +1483,7 @@ final class FeatureFormViewTests: XCTestCase { placeholderImage.tap() XCTAssertTrue(thumbnailImage.waitForExistence(timeout: 10)) - XCTAssertFalse(placeholderImage.exists) + XCTAssertFalse(placeholderImage.waitForExistence(timeout: 10)) XCTAssertFalse(downloadIcon.exists) } @@ -1418,7 +1516,7 @@ final class FeatureFormViewTests: XCTestCase { titleTextField.typeText("Los Angeles") - XCTAssertTrue(losAngelesText.exists) + XCTAssertTrue(losAngelesText.waitForExistence(timeout: 10)) } /// Test plain text @@ -1477,11 +1575,15 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(elementTitle, direction: .up, velocity: .fast) + XCTAssertTrue( elementTitle.waitForExistence(timeout: 5), "The element \"Associations\" doesn't exist." ) + app.scrollToElement(filterResults3, direction: .up) + XCTAssertTrue( filterResults1.waitForExistence(timeout: 5), "The filter result \"Connected\" doesn't exist." @@ -1552,11 +1654,15 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(elementTitle, direction: .up, velocity: .fast) + XCTAssertTrue( elementTitle.waitForExistence(timeout: 5), "The element \"Associations\" doesn't exist." ) + app.scrollToElement(filterResults, direction: .up, velocity: .slow) + XCTAssertTrue( filterResults.waitForExistence(timeout: 5), "The filter result \"Content\" doesn't exist." @@ -1589,6 +1695,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(elementTitle, direction: .up, velocity: .fast) + XCTAssertTrue( elementTitle.waitForExistence(timeout: 5), "The element \"Associations\" doesn't exist." @@ -1633,6 +1741,8 @@ final class FeatureFormViewTests: XCTestCase { openTestCase() assertFormOpened(titleElement: formTitle) + app.scrollToElement(elementTitle, direction: .up, velocity: .fast) + XCTAssertTrue( elementTitle.waitForExistence(timeout: 5), "The element \"Associations\" doesn't exist." @@ -1692,7 +1802,7 @@ final class FeatureFormViewTests: XCTestCase { ) // Tap the "Discard" option. Note that some platforms may use "Discard Edits". - discardEditsButton.tap() + discardEditsButton.firstMatch.tap() // Access the new `FeatureForm` // Expectation: the form title should be "Electric Distribution Junction" @@ -1875,3 +1985,52 @@ private extension String { ) } } + +extension XCUIApplication { + /// Scrolls up until the target element is hittable or max swipes reached. + func scrollToElement( + _ element: XCUIElement, + direction: ScrollDirection, + maxSwipes: Int = 10, + velocity: XCUIGestureVelocity? = nil + ) { + let target = collectionViews.firstMatch + var swipes = 0 + while !element.isHittable && swipes < maxSwipes { + switch (direction, velocity) { + case (.up, .none): + target.swipeUp() + case (.up, .some(let velocity)): + target.swipeUp(velocity: velocity) + case (.down, .none): + target.swipeDown() + case (.down, .some(let velocity)): + target.swipeDown(velocity: velocity) + } + swipes += 1 + } + } +} + +enum ScrollDirection { + case down + case up +} + +extension XCUIElement { + func tapSwitch() { +#if os(visionOS) + tap() +#else + switches.firstMatch.tap() +#endif + } +} + +extension XCTestCase { + func skipForCatalystScrollBehavior() throws { +#if targetEnvironment(macCatalyst) + throw XCTSkip("Scrolling in UI tests on Mac Catalyst is inconsistent (FB19836397)") +#endif + } +}