Scroll until element is visible iOS UI Automation with Xcode 7
Asked Answered
P

10

40

So with the new Xcode update Apple has revamped the way we do UI testing. In instruments we used java script function "isVisible" to determine if our targeted element is visible.

I'm trying to replicate this in Objective-C but I can't seem to find the equivalent of this. I have a table view, a prototype cell with two labels on it. This prototype cell is reused 50 times lets say.

I'm trying to scroll until the last cell is visible, I did this by doing this:

if (![[[[[[XCUIApplication alloc] init].tables childrenMatchingType:XCUIElementTypeCell] matchingIdentifier:@"cell"] elementBoundByIndex:49].staticTexts[@"text"] exists]) {
        [[[[[[XCUIApplication alloc] init].tables childrenMatchingType:XCUIElementTypeCell] matchingIdentifier:@"cell"] elementBoundByIndex:0].staticTexts[@"text"] swipeUp];
}

But this won't swipe since the element exists when the view is loaded. Please help because this is driving me crazy.

Pecten answered 18/9, 2015 at 7:44 Comment(0)
C
60

You should extend the XCUIElement's method list. The first method (scrollToElement:) will be called on the tableView, the second extension method helps you decide if the element is on the main window.

extension XCUIElement {

    func scrollToElement(element: XCUIElement) {
        while !element.visible() {
            swipeUp()
        }
    }

    func visible() -> Bool {
        guard self.exists && !CGRectIsEmpty(self.frame) else { return false }
        return CGRectContainsRect(XCUIApplication().windows.elementBoundByIndex(0).frame, self.frame)
    }

}

The scrolling code should look like this (e.g. scrolling to last cell):

func testScrollTable() {
    let app = XCUIApplication()
    let table = app.tables.elementBoundByIndex(0)
    let lastCell = table.cells.elementBoundByIndex(table.cells.count-1)
    table.scrollToElement(lastCell)
}

Swift 3:

extension XCUIElement {
    func scrollToElement(element: XCUIElement) {
        while !element.visible() {
            swipeUp()
        }
    }

    func visible() -> Bool {
        guard self.exists && !self.frame.isEmpty else { return false }
        return XCUIApplication().windows.element(boundBy: 0).frame.contains(self.frame)
    }
}
Changchangaris answered 29/11, 2015 at 18:50 Comment(5)
YOU ARE A MAGICIAN!!Sanjuanitasank
This solution can scroll past the desired cell :(Tuesday
I don't believe the visible method is strictly required, couldn't you use the build in isHittable property to do the same thing?Fiorenza
I tried all solutions but not working. I find staticText using containing and then firstMatch method on a table view. The The cell is at first position but table view seems to get loaded fully. The visible function fails at find window. Any suggestion? I am in iOS 14 using Xcode 12.4Louise
This method assumes that the element is lower down on the scroll view. What if it is higher up?Metalworking
A
21

All the previous answers are not 100% fail proof. The problem I was facing is that swipeUp() has a larger offset and I couldn't find a way to stop the scrolling when I have the element in view port. Sometimes the element gets scrolled away because of the excessive scroll and as a result test case fails. However I managed to control the scroll using the following piece of code.

/**
Scrolls to a particular element until it is rendered in the visible rect
- Parameter elememt: the element we want to scroll to
*/
func scrollToElement(element: XCUIElement)
{
    while element.visible() == false
    {
        let app = XCUIApplication()
        let startCoord = app.collectionViews.element.coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.5))
        let endCoord = startCoord.coordinateWithOffset(CGVector(dx: 0.0, dy: -262));
        startCoord.pressForDuration(0.01, thenDragToCoordinate: endCoord)
    }
}

func visible() -> Bool
{
    guard self.exists && self.hittable && !CGRectIsEmpty(self.frame) else
    {
        return false
    }

    return CGRectContainsRect(XCUIApplication().windows.elementBoundByIndex(0).frame, self.frame)
}

Note : Please use app.tables if your view is tableview based

Arvo answered 24/7, 2016 at 4:42 Comment(6)
Update for Swift3 let startCoord = XCUIApplication().collectionViews.element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))\n let endCoord = startCoord.withOffset(CGVector(dx: 0.0, dy: -262))\n startCoord.press(forDuration: 0.01, thenDragTo: endCoord)Medina
Thanks for this. If you have a button directly in the middle of the View, a normal swipe gesture is not working. So i had to change the scroll position but didn't know how. This solution is very cool with a little but, the view scrolling stops if a tiny amount of the frame is visible. I will think about a solution for the visibility for the whole frame. But thank you :)Tillett
awesome! I tried pretty much all the answers here and this one was the one which worked, thanks!Narine
The last part CGRectContainsRect didn't work for me, but wasn't necessary anyway. Just changed the check to self.exists && self.hittable.Jello
I like this concept, but I've provided even more improved version.Attitudinarian
I am having issue with this approach when testing on older device like iPhone 5s. The "scroll" happens out side of the table. Any ideas?Viole
G
17

Solutions using swipeUp() and swipeDown() are not ideal because they can potentially scroll past the target element due to the momentum of the swipe. After much searching and frustration I found a magical method on XCUICoordinate:

func press(forDuration duration: TimeInterval, thenDragTo otherCoordinate: XCUICoordinate)

So we can do something like:

let topCoordinate = XCUIApplication().statusBars.firstMatch.coordinate(withNormalizedOffset: .zero)
let myElement = XCUIApplication().staticTexts["My Element"].coordinate(withNormalizedOffset: .zero)
// drag from element to top of screen (status bar)
myElement.press(forDuration: 0.1, thenDragTo: topCoordinate)

As far as checking whether something is visible goes, you want to use isHittable in conjunction with exists. see scrollDownToElement in the extension below

Here's a handy extension that will scroll until an element is on screen and then scroll that element to the top of the screen :)

extension XCUIApplication {
    private struct Constants {
        // Half way accross the screen and 10% from top
        static let topOffset = CGVector(dx: 0.5, dy: 0.1)

        // Half way accross the screen and 90% from top
        static let bottomOffset = CGVector(dx: 0.5, dy: 0.9)
    }

    var screenTopCoordinate: XCUICoordinate {
        return windows.firstMatch.coordinate(withNormalizedOffset: Constants.topOffset)
    }

    var screenBottomCoordinate: XCUICoordinate {
        return windows.firstMatch.coordinate(withNormalizedOffset: Constants.bottomOffset)
    }

    func scrollDownToElement(element: XCUIElement, maxScrolls: Int = 5) {
        for _ in 0..<maxScrolls {
            if element.exists && element.isHittable { element.scrollToTop(); break }
            scrollDown()
        }
    }

    func scrollDown() {
        screenBottomCoordinate.press(forDuration: 0.1, thenDragTo: screenTopCoordinate)
    }
}

extension XCUIElement {
    func scrollToTop() {
        let topCoordinate = XCUIApplication().screenTopCoordinate
        let elementCoordinate = coordinate(withNormalizedOffset: .zero)

        // Adjust coordinate so that the drag is straight up, otherwise
        // an embedded horizontal scrolling element will get scrolled instead
        let delta = topCoordinate.screenPoint.x - elementCoordinate.screenPoint.x
        let deltaVector = CGVector(dx: delta, dy: 0.0)

        elementCoordinate.withOffset(deltaVector).press(forDuration: 0.1, thenDragTo: topCoordinate)
    }
}

Gist over here with added scrollUp methods

Glutenous answered 26/10, 2017 at 0:19 Comment(1)
With xcode >= 9.1 only working solution, tnx for sharingGowan
E
4

Expanding on @Kade's answer, in my case, had to account for tabbar in scrollToElement, else might get a tabbar button tapped if the view was under the tabbar:

func scrollToElement(element: XCUIElement) {
    while !element.visible() {
        swipeUp()
    }
    // Account for tabBar
    let tabBar = XCUIApplication().tabBars.element(boundBy: 0)
    if (tabBar.visible()) {
        while element.frame.intersects(tabBar.frame) {
            swipeUp()
        }
    }
}
Elswick answered 4/10, 2016 at 20:43 Comment(0)
A
4

Here is my version which I think is bullet proof (swift 4.0):

import XCTest

enum TestSwipeDirections {
    case up
    case down
    case left
    case right
}

fileprivate let min = 0.05
fileprivate let mid = 0.5
fileprivate let max = 0.95

fileprivate let leftPoint = CGVector(dx: min, dy: mid)
fileprivate let rightPoint = CGVector(dx: max, dy: mid)
fileprivate let topPoint = CGVector(dx: mid, dy: min)
fileprivate let bottomPoint = CGVector(dx: mid, dy: max)

extension TestSwipeDirections {
    var vector: (begin: CGVector, end: CGVector) {
        switch self {
        case .up:
            return (begin: bottomPoint,
                    end:   topPoint)
        case .down:
            return (begin: topPoint,
                    end:   bottomPoint)
        case .left:
            return (begin: rightPoint,
                    end:   leftPoint)
        case .right:
            return (begin: leftPoint,
                    end:   rightPoint)
        }
    }
}

extension XCUIElement {
    @discardableResult func swipeOnIt(_ direction: TestSwipeDirections,
                                      swipeLimit: Int = 6,
                                      swipeDuration: TimeInterval = 1.0,
                                      until: () -> Bool) -> Bool {
        XCTAssert(exists)

        let begining = coordinate(withNormalizedOffset: direction.vector.begin)
        let ending = coordinate(withNormalizedOffset: direction.vector.end)

        var swipesRemaining = swipeLimit
        while !until() && swipesRemaining > 0 {
            begining.press(forDuration: swipeDuration, thenDragTo: ending)
            swipesRemaining = swipesRemaining - 1
        }
        return !until()
    }

    @discardableResult func swipeOnIt(_ direction: TestSwipeDirections,
                                      swipeLimit: Int = 6,
                                      swipeDuration: TimeInterval = 1.0,
                                      untilHittable element: XCUIElement) -> Bool {
        return swipeOnIt(direction, swipeLimit: swipeLimit, swipeDuration: swipeDuration) { element.isHittable }
    }

    @discardableResult func swipeOnIt(_ direction: TestSwipeDirections,
                                      swipeLimit: Int = 6,
                                      swipeDuration: TimeInterval = 1.0,
                                      untilExists element: XCUIElement) -> Bool {
        return swipeOnIt(direction, swipeLimit: swipeLimit, swipeDuration: swipeDuration) { element.exists }
    }
}

It take into account that item may not be found (in this case it should not hang). Also scroll is performed in steps of size of the item so search element will not pass through visible area what is possible in case of swipe.

Attitudinarian answered 20/4, 2018 at 15:4 Comment(3)
How do you use this? Can you provide an example?Labrie
note this is an extenison of XCUIElement. So you have to find element to swipe on and then invoke swipeOnIt on it. It swipes until condition from closure is meet. Can't provide a full example since for a year I don't code in Swift.Attitudinarian
Got it! For some reason it wasn't obvious that I can scroll down on this code sample to see the actual extension. Thanks for replying!Labrie
M
3

Unfortunately .exists doesn't confirm that an element is currently visible - something like this still isn't perfect but it will provide more reliable validation working with table or collection view cells:

extension XCUIElement {
    var displayed: Bool {
        guard self.exists && !CGRectIsEmpty(frame) else { return false }
        return CGRectContainsRect(XCUIApplication().windows.elementBoundByIndex(0).frame, frame)
    }
}

then you can write a simple loop like:

func scrollDownUntilVisible(element: XCUIElement) {
    while !element.displayed {
        swipeDown()
    }
}
Melissiamelita answered 20/11, 2015 at 0:27 Comment(1)
You could turn displayed into a computed propertyChafe
T
2

Update to @ravisekahrp's answer for newer Swift:

extension XCUIElement {
    func isVisible() -> Bool {
        if !self.exists || !self.isHittable || self.frame.isEmpty {
            return false
        }

        return XCUIApplication().windows.element(boundBy: 0).frame.contains(self.frame)
    }
}

extension XCTestCase {
    func scrollToElement(_ element: XCUIElement) {
        while !element.isVisible() {
            let app = XCUIApplication()
            let startCoord = app.tables.element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
            let endCoord = startCoord.withOffset(CGVector(dx: 0.0, dy: -262))
            startCoord.press(forDuration: 0.01, thenDragTo: endCoord)
        }
    }
}
Tuesday answered 9/5, 2019 at 8:49 Comment(0)
I
1

you can do something like this:

extension XCUIElement {
    internal func scrollToElement(element: XCUIElement) {
        while !element.exists {
            swipeDown()
        }
    }
}

and than use scrollToElement to find element

Inapproachable answered 26/10, 2015 at 17:15 Comment(3)
small correction: replace "exists" with "hittable". That worked for me.Reminiscence
check my version. This one will lead to test hanging if element can't be find (test should fail). Also it is possible that during the swipe searched element will pass through visible area.Attitudinarian
There are some solution below that are more accurate as you don't want to check if it is hittable but actually if it is displayedStatistics
C
0

The problem here is, the swipe momentum scrolls the required cell out of the screen in most cases. So we will end up with the false swipes in our searching part.

Try to scroll to the cell which is placed after the last hittable cell in your current table view with all above answers. They will scroll the required elements and in most cases, we can't find the required cell.

Requirements:

  • Exact scroll to the cell which is after the last visible cell
  • Should throw error or stop scroll if we reach the table bottom

My solution:

public let app = XCUIApplication()

extension XCUIElement {
    
    func scrollTo(_ element: XCUIElement) {
        if self.elementType == .table {
            if element.isHittable { return }
            let lastCell = self.cells.element(boundBy: self.cells.count-1)
            let yOffset = calculatedYOffset()
            
            while !element.isHittable
            {
                if lastCell.isHittable {
                    //Error - Table bottom reached
                }
                
                let start = self.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
                let end = start.withOffset(CGVector(dx: 0.0, dy: -yOffset));
                start.press(forDuration: 0.01, thenDragTo: end)
            }
        } else {
            // Error - Only applicable for table views
        }
    }
    
    func calculatedYOffset() -> Double {
        var indexOfLastVisibleCell = 0
        for i in 0...self.cells.count-1 {
            if self.cells.element(boundBy: i).visible() {
                indexOfLastVisibleCell = i
            } else {
                if indexOfLastVisibleCell != 0 { break }
            }
        }
        
        let lastVisibleCellEndYPosition = Double(self.cells.element(boundBy: indexOfLastVisibleCell).frame.maxY)
        let adjustmentYValue = Double(self.cells.firstMatch.frame.minY)
        let screenScale = Double(UIScreen.main.scale)
        
        return (lastVisibleCellEndYPosition-adjustmentYValue)/screenScale
    }
    
    func visible() -> Bool {
        guard self.exists && self.isHittable && !self.frame.isEmpty else { return false }
        return app.windows.element(boundBy: 0).frame.contains(self.frame)
    }
}

Now, this should work:

App.tables["Bar"].scrollTo(App.cells.staticTexts["Foo"])

Pros:

  • It controls the momentum of the swipe
  • Notifies if we have reached the last cell

Cons:

  • For the first time, the calculation part takes time to check the hittable cells

Note: This answer will only work with table views and swipes towards the bottom

Cogency answered 29/4, 2021 at 12:46 Comment(0)
T
-2

in swift 4.2, if your element exist at bottom frame of table view or top frame of table view you can use this command to scroll up and scroll down to find element

let app = XCUIApplication()
app.swipeUp()

or

app.swipeDown()
Teflon answered 29/4, 2019 at 15:42 Comment(1)
This assumes your table view uses the entire screen.Ftc

© 2022 - 2024 — McMap. All rights reserved.