I have an app where a Page Control is used to indicate multiple of screens. When the user taps the page control (for example: to the right the of the current selected page), the scroll view scrolls to the following screen. It works fine.
I wanted to write a UI Test for this scenario. I found out that I cannot tap a specific page control "dot" to trigger that action. What I can check is that the Page Control exists and the current selected page.
Another issue is that even if I am able to do so, how can I check that the scroll view has scrolled already? I can only access the frame of the scroll view, not its bounds.
I have just started using Xcode UITest and it's letting me down. I thought it would be powerful by now (it was introduced by Xcode 7 and it was available before as UI Automation), but it seems it's not yet.
I will revert back to Unit Testing + manual functional testing. If anyone has other thoughts, please share it.
Don't give up so soon, Xcode UITest can be frustrating at times but once you figured out what tricks to use, they are quite powerful:
You test your UIPageControl like this:
func testPageIndicator() {
let app = XCUIApplication()
app.launch()
let scrollView = app.scrollViews.element(boundBy: 0)
let pageIndicator = app.pageIndicators.element(boundBy: 0)
// test that page indicator is updated when user scrolls to page 2
scrollView.swipeLeft()
XCTAssertEqual(pageIndicator.value as? String, "page 2 of 3")
// test that scrollview scrolls to page 3 when user taps right of the current page in page indicator
pageIndicator.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.2)).tap()
XCTAssert(app.otherElements["page_3"].waitForExistence(timeout: 2))
XCTAssertFalse(app.otherElements["page_2"].exists)
XCTAssertEqual(pageIndicator.value as? String, "page 3 of 3")
}
You can tell the test where to tap on a UIElement by using the coordinate method. You hand a CGVector object to that method that describes where to tap.
For example a CGVector(dx: 0.9, dy: 0.2) tells the test to tap the element at the point that lies at 90% of its width and 20% of its height.
You can use this to tap left of right of the current page in the UIPageIndicator(see above code example)
To test if you are on the correct page in the UIScrollView you can set each pages accessibilityIdentifier to a unique string and then assert that the UIElement with that identifier exists after you tapped the page control.
In my case the pages are 3 UIViews. I set their accessibilityIdentifier to "page_1", "page_2" and "page_3" and then assert that "page_3" exists and "page_2" doesn't.
This is a extension I have for getting the number of pages and current page. It uses regex to find two numbers in the string value and assumes the bigger one is the number of pages and the smaller one is current page.
What is nice about it is it works even if your device/simulator is not set in English.
extension XCUIElement {
func currentPageAndNumberOfPages() -> (Int, Int)? {
guard elementType == .pageIndicator, let pageIndicatorValue = value as? String else {
return nil
}
// Extract two numbers. This should be language agnostic.
// Examples: en:"3 of 5", ja:"全15ページ中10ページ目", es:"4 de 10", etc
let regex = try! NSRegularExpression(pattern: "^\\D*(\\d+)\\D+(\\d+)\\D*$", options: [])
let matches = regex.matches(in: pageIndicatorValue, options: [], range: NSRange(location: 0, length: pageIndicatorValue.count))
if matches.isEmpty {
return nil
}
let group1 = matches[0].range(at: 1)
let group2 = matches[0].range(at: 2)
let numberString1 = pageIndicatorValue[Range(group1, in: pageIndicatorValue)!]
let numberString2 = pageIndicatorValue[Range(group2, in: pageIndicatorValue)!]
let number1 = Int(numberString1) ?? 0
let number2 = Int(numberString2) ?? 0
let numberOfPages = max(number1, number2)
var currentPage = min(number1, number2)
// Make it 0 based index
currentPage = currentPage > 0 ? (currentPage - 1) : currentPage
return (currentPage, numberOfPages)
}
/// return current page index (0 based) of an UIPageControl referred by XCUIElement
func currentPage() -> Int? {
return currentPageAndNumberOfPages()?.0 ?? nil
}
/// return number of pages of an UIPageControl referred by XCUIElement
func numberOfPages() -> Int? {
return currentPageAndNumberOfPages()?.1 ?? nil
}
}
And you can use it like:
let pageCount = app.pageIndicators.element(boundBy: 0).numberOfPages() ?? 0
for _ in 0 ..< pageCount {
app.pageIndicators.element(boundBy: 0).swipeLeft()
}
Related
I'm doing pagination using UITableViewDataSourcePrefetching.
The values will be taken from the Realm local storage.
I will get an array of objects. These values will be applied to the existing UITableViewDiffableDataSource datasource.
After applying snapshot the tableview scrolling to the top.
I have verified that all my ChatMessage objects has unique hashValues.
How can I prevent the scrolling?
Link to the video TableView_scroll_issue_video
Given my code snippet
private func appendLocal(chats chatMessages: [ChatMessage]) {
var sections: [String] = chatMessages.map({ $0.chatDateTime.toString() })
sections.removeDuplicates()
guard !sections.isEmpty else { return }
var snapshot = dataSource.snapshot()
let chatSections = snapshot.sectionIdentifiers
sections.forEach { section in
let messages = chatMessages.filter({ $0.chatDateTime.toString() == section })
/// Checking the section is already exists in the dataSource
if let index = chatSections.firstIndex(of: section) {
let indexPath = IndexPath(row: 0, section: index)
/// Checking dataSource already have some messages inside the same section
/// If messages available then add the recieved messages to the top of existing messages
/// else only section is available so append all the messages to the section
if let item = dataSource.itemIdentifier(for: indexPath) {
snapshot.insertItems(messages, beforeItem: item)
} else {
snapshot.appendItems(messages, toSection: section)
}
} else if let firstSection = chatSections.first {
/// Newly receieved message's section not available in the dataSource
/// Add the section before existing section
/// Add the messages to the newly created section
snapshot.insertSections([section], beforeSection: firstSection)
snapshot.appendItems(messages, toSection: section)
} else {
/// There is no messages available append the new section and messages
snapshot.appendSections([section])
snapshot.appendItems(messages, toSection: section)
}
}
dataSource.apply(snapshot, animatingDifferences: false)
}
I think that everything works correctly. You scroll to the "past" and eventually you want to add more "old" messages. So you insert new messages into the indexPath (0-0). Your contentOffset stays the same so you see the "old" messages.
Calculating and preserving the visually correct contentOffset is possible but hard, and it's also pretty fragile. Whatever goes wrong your scroll position will "jump". You don't want to touch your contentOffset.
What you might actually want is to reuse the commonly famous trick that all popular messengers use. You want your "newest" messages to actually be on the bottom of the screen, and in the same time you'd like the newest message to be the first one in your dataSource.
So you need to flip the tableView vertically and then flip every cell vertically as well. Doing so you'll get more "natural" data source when the newest messages get inserted into the indexPath (0-0), and "older" messages get appended to the end of your message array.
let t = CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
tableView.transform = t
cell.transform = t
That might sound crazy, but give it a try and you'll be impressed.
You can try the following:
dataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
It's only available from iOS 15.0
Also common mistake is using UUID().uuidString.
Make sure that the id of each cell is fixed. UITableViewDiffableDataSource will not be able to properly calculate changes and it will reload the whole table if ids are updated.
You can try creating a new snapshot instead of referencing & mutating the current snapshot of your dataSource.
After looking a bit into it, I realized that you apply the snapshot without animation.
That means that on ios versions 13-14, you are not performing a diff, but actually reloading the data, which means the whole state of the dataSource is lost. Hence the scrolling position is also reset.
You need to apply the snapshot with animation on versions 13, 14.
There are a few work arounds if you insist on not animating the changes while setting ‘animated’ as true, but I don’t see, from a UX perspective, why you’d want that.
If anyone facing the issue, I have resolved the issue by adding the section and items one at time with animation instead of in batches. The batching appears to be causing the jumping issue more than if done individually in the loop. The code is given below,
func appendLocal(chats chatMessages: [ChatMessage]) {
var sections: [String] = chatMessages.map({ $0.chatDateTime.toString() })
sections.removeDuplicates()
guard !sections.isEmpty else { return }
var snapshot = dataSource.snapshot()
let chatSections = snapshot.sectionIdentifiers
for section in sections {
let messages = chatMessages.filter({ $0.chatDateTime.toString() == section })
if messages.isEmpty {
continue
}
/// Checking the section is already exists in the dataSource
if let index = chatSections.firstIndex(of: section) {
let indexPath = IndexPath(row: 0, section: index)
/// Checking dataSource already have some messages inside the same section
/// If messages available then add the recieved messages to the top of existing messages
/// else only section is available so append all the messages to the section
if let item = dataSource.itemIdentifier(for: indexPath) {
snapshot.insertItems(messages, beforeItem: item)
} else {
snapshot.appendItems(messages, toSection: section)
}
} else if let firstSection = chatSections.first {
/// Newly receieved message's section not available in the dataSource
/// Add the section before existing section
/// Add the messages to the newly created section
viewModel.chatSections.insert(section, at: 0)
snapshot.insertSections([section], beforeSection: firstSection)
snapshot.appendItems(messages, toSection: section)
} else {
/// There is no messages available append the new section and messages
viewModel.chatSections.append(section)
snapshot.appendSections([section])
snapshot.appendItems(messages, toSection: section)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
I am developing a tableView where each cell consists of AVPlayer and has the height of view.frame.size.height and paging is also enabled. so essentially it will work similar to tiktok application feed. The problem I am having is that I've to destroy AVPlayer in prepareForReuse to show new video when a cellForRow is called otherwise if I don't destroy it and user scroll fast, the older video appear for a second and if I destroy player each time before using then AVPlayer takes a second to load and in between show black screen. It works but the result is not elegant.
So is there any way I can preload cells and save them in an array. Like an array which will consist of three independent cells. and we can change the value in them when a user scroll
For example
[] represents cell on screen
0 [1] 2
array[1] would always be the cell on screen
array[0] previous
array[2] next
if user scroll down then
array[0] = array[1]
array[1] = array[2]
array[2] = create next one (proactively)
if user scroll up then
let array1 = array[1]
array[1] = array[0]
array[0] = array1
array[2] = create new one
This sounds more like a job for a collection view. You should be able to achieve the smooth loading you want by prefetching and populating the cell for display once the data is received.
https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data
Prefetching Collection View Data
Load data for collection view cells before they are displayed.
A collection view displays an ordered collection of cells in
customizable layouts. The UICollectionViewDataSourcePrefetching
protocol helps provide a smoother user experience by prefetching the
data necessary for upcoming collection view cells. When you enable
prefetching, the collection view requests the data before it needs to
display the cell. When it’s time to display the cell, the data is
already locally cached.
The image below shows cells outside the bounds of the collection view
that have been prefetched.
If this doesn't do what you are looking for, please post your code.
Update:
I spent a lot of time trying to solve this problem with tableView but was unable to do so. I ended up making a custom tableview with scrollview which have the similar implementation asked in the question. So it is doable with scrollview to preload cells. Code provided below is just for reference
func scrollViewDidScroll(_ scrollView: UIScrollView) {
//Condition for first cell
if currentIndex == 0 && (scrollView.contentOffset.y > view.frame.minY) {
//pageIndex will only be one if the user completly scrolls the page, if he scroll half a returns it will remain 0
if pageIndex == 1 {
//so if the user completely scroll to page 1 the change the previous index to 0
//new current index will be 1
//call scrollForward which will prepare the cell for index 2
previousIndex = currentIndex
currentIndex = Int(pageIndex)
if shouldCallScrollMethods {
scrollForward()
}
guard let currentCell = getCellOnScreen() else { return }
currentCell.refreshBottomBarView()
}
//Condition for rest of the cells
} else {
//this condition checks if the user completly scroll to new page or just drag the scrollview a bit gets to old position
if (pageIndex != currentIndex) {
//Update the previous and current to new values
previousIndex = currentIndex
currentIndex = Int(pageIndex)
//Checks if the user is scroll down the calls scrollForwad else scrollBackward
if shouldCallScrollMethods {
if currentIndex > previousIndex {
scrollForward()
} else {
scrollBackward()
}
}
guard let currentCell = getCellOnScreen() else { return }
currentCell.refreshBottomBarView()
}
}
}
private func scrollForward() {
//print("scrollForward")
addCell(at: currentIndex + 1, url: getPlayerItem(index: currentIndex + 1))
removeCell(at: currentIndex - 2)
}
private func scrollBackward() {
//Condition to check if the element is not at 0
//print("scrollBackward")
addCell(at: currentIndex - 1, url: getPlayerItem(index: currentIndex - 1))
removeCell(at: currentIndex + 2)
}
I made my very first iOS app. But there are two annoying bugs which I cannot get rid of. I hope somebody can help me!
The app is supposed to train to read musical notation. The user specifies his instrument and level (on the previous viewcontroller) and based on that, it places random notes in musical notation on the screen. The user should match those notes in textfields and the app keeps track of the score and advances a level after ten right answers.
However, somehow I'm having problems with the function which generates the random notes. The function for some reason gets called twice, the first time it generates the notes, saves them in a global variable and creates the labels with the notes. The second time, it changes the global variable but not the labels. It returns the following error message this time: 2018-09-29 23:08:37.279170+0200 MyProject[57733:4748212] Warning: Attempt to present <MyProject.ThirdViewController: 0x7fc709125890> on <MyProject.SecondViewController: 0x7fc70900fcd0> whose view is not in the window hierarchy!
Because of this, the user answers the question on the screen, but the app thinks it's the wrong answer, because it has the second answer stored.
The second time the user answers a question, the function is only called once, but the read-out from the text fields doesn't update to the new values, but keeps the same as with the first question.
Here is the code which gives the problems:
import UIKit
class ThirdViewController: UIViewController
{
// snip
func setupLabels() {
// snip
// here the random notes are created, this is function is called multiple times for some reason
let antwoord = Noten()
let antwoordReturn = antwoord.generateNoten(instrument: instrument, ijkpunt: ijkpunt, aantalNoten: aantalNoten-1)
let sleutel = antwoordReturn.0
let heleOpgave = antwoordReturn.1
print(heleOpgave)
print(PassOpgave.shared.category)
let heleOpgaveNummers = antwoordReturn.2
// snip
var a = 0
while a < aantalNoten {
// the labels are created, no problems there
let myTekstveld = UITextField(frame: CGRect(x: labelX, y: labelY + 150, width: labelWidth, height: labelHeight / 2))
myTekstveld.backgroundColor = UIColor.white
myTekstveld.textAlignment = .center
myTekstveld.placeholder = "?"
myTekstveld.keyboardType = UIKeyboardType.default
myTekstveld.borderStyle = UITextField.BorderStyle.line
myTekstveld.autocorrectionType = .no
myTekstveld.returnKeyType = UIReturnKeyType.done
myTekstveld.textColor = UIColor.init(displayP3Red: CGFloat(96.0/255.0), green: CGFloat(35.0/255.0), blue: CGFloat(123.0/255.0), alpha: 1)
myTekstveld.delegate = self as? UITextFieldDelegate
myTekstveld.tag = a + 1
view.addSubview(myTekstveld)
a += 1
labelX += labelWidth
}
// the button is created
}
override func viewDidLoad()
{
super.viewDidLoad()
// snip
setupLabels()
}
#objc func buttonAction(sender: UIButton!) {
// snip
// here the text from the text fields is read, but this only works the first time the buttonAction is called, the next times, it simply returns the first user input.
while a <= aantalNoten {
if let theLabel = view.viewWithTag(a) as? UITextField {
let tekstInput = theLabel.text!
userInput.append(tekstInput)
}
a += 1
}
// snip
setupLabels()
return
}
// snip
You have two instances of ThirdViewController when you don't mean to.
This error is very telling:
2018-09-29 23:08:37.279170+0200 MyProject[57733:4748212] Warning: Attempt to present <MyProject.ThirdViewController: 0x7fc709125890> on <MyProject.SecondViewController: 0x7fc70900fcd0> whose view is not in the window hierarchy!
This is telling you that SecondViewController is trying to create ThirdViewController when SecondViewController is not even on the screen. This suggests that the mistake is in SecondViewController (perhaps observing notifications or other behaviors when not on screen). It's possible of course that you also have two instances of SecondViewController.
I suspect you're trying to build all of this by hand rather than letting Storyboards do the work for you. That's fine, but these kinds of mistakes are a bit more common in that case. The best way to debug this further is to set some breakpoints and carefully check the address of the objects (0x7fc709125890 for example). You'll need to hunt down where you're creating an extra one.
Your genreteNoten method is being called multiple times because it is called from setupLabels which is In turn called from viewDidLoad.
viewDidLoad may be called multiple times and your code should account for that. As it says in this answer to a similar question:
If you have code that only needs to run once for your controller use -awakeFromNib.
I managed to partially solve my second problem myself (that the read-out from the text fields was not updating to the second answer) by not creating them again.
I added some code to setupLabels function to only create the text fields if there was no input already:
let myTekstveld = UITextField()
if (view.viewWithTag(a+1) as? UITextField) != nil {
}
else {
myTekstveld.frame = CGRect(x: labelX, y: labelY + 100, width: labelWidth, height: labelHeight / 2)
// snip
myTekstveld.tag = a + 1
view.addSubview(myTekstveld)
}
The app works as expected now, the only problem is that the text fields are not cleared after each question.
This is my simple UITest (customizing order of tabs in tabbarcontroller):
func testIsOrderOfTabsSaved() {
let app = XCUIApplication()
let tabBarsQuery = app.tabBars
tabBarsQuery.buttons["More"].tap()
app.navigationBars["More"].buttons["Edit"].tap()
tabBarsQuery.buttons["Takeaway"].swipeLeft()
tabBarsQuery.buttons["In Restaurant"].swipeLeft()
//here, how to get position of moved button with text "In Restaurant"?
NOTE:
It is possible to get XCUIElement from XCUIElementQuery by index. Can I do this fro the other way?
It seems that the queries automatically return in order based on position on screen.
for i in 0...tabsQuery.buttons.count {
let ele = tabsQuery.buttons.elementBoundByIndex(i)
}
Where the index i represents the position of the button in the tab bar, 0 being the leftmost tab, i == tabsQuery.buttons.count being the rightmost.
You have various ways to create a position test. The simplest way is to get buttons at indices 0 and 1, then get two buttons by name and compare the arrays are equal: (written without testing)
let buttonOrder = [tabBarsQuery.buttons.elementAtIndex(0), tabBarsQuery.buttons.elementAtIndex(1)]
let takeawayButton = buttons["Takeaway"];
let restaurantButton = buttons["In Restaurant"];
XCTAssert(buttonOrder == [takeawayButton, restaurantButton])
Another option is to directly get the frame of each button and assert that one X coordinate is lower than the other.
To answer your specific question about getting the index of an XCUIElement in a XCUIElementQuery, that's absolutely possible. Just go through all the elements in the query and return the index of the first one equal to the element.
An XCUIElement alone isn't able to tell you its position in within the XCUIElementQuery. You can search the XCUIElementQuery to discover its offset if you know something about the XCUIElement. In the below let's imagine that "More" is the identifier (change 'identifier' to 'label' if that is what you're working with).
If you want to find the offset of the "More" button (as posted in the original question) then:
var offsetWithinQuery : Int? = nil // optional since it's possible the button isn't found.
for i in 0...tapBarsQuery.buttons.count{
if tapBarsQuery.elementAtIndex(i).indentifier == "More" {
offSetWithinQuery = i
}
After the above loop exits, you'll either have the offset or it'll be nil if "More" isn't found.
I am trying to make my mapview accessible, but I have an isuue in doing so:
If I try to make the mapView accessible,by doing this:
self.mapView.isAccessibilityElement=YES;
Then, the map view is not read by the voice over.
If I set like this:
self.mapView.isAccessibilityElement=NO;
Then the voice over is reading everything in the map, streets, buildings, my current location and my annotations.
I have given the accessibility label and hints to my annotations,but I havent provided any other value to the mapview.
I also tried by setting the accessibility elements for map view:
[self.mapView setAccessibilityElements:#[self.mapView.annotations,self.mapView.userLocation]];
But still no luck.
Is there anyway to make voice over read only the annotations and neglect remaining elements like streets,buildings?
I think you're having difficulty because an MKMapView is a UIAccessibilityContainer, and so isAccessibilityElement if false by default.
You should look into making custom voice over rotors that allow the user to navigate your app by only your annotations.
This is a good approach, because it gives the user a custom, app specific way to navigate your map, while also not taking anything away from MKMapView's built in accessibility.
There are hardly any examples online that go into detail about creating custom rotors, but I just successfully created one doing exactly what you need it to do. I followed the WWDC Session 202 (begins at 24:17).
Here's my code:
func configureCustomRotors() {
let favoritesRotor = UIAccessibilityCustomRotor(name: "Bridges") { predicate in
let forward = (predicate.searchDirection == .next)
// which element is currently highlighted
let currentAnnotationView = predicate.currentItem.targetElement as? MKPinAnnotationView
let currentAnnotation = (currentAnnotationView?.annotation as? BridgeAnnotation)
// easy reference to all possible annotations
let allAnnotations = self.mapView.annotations.filter { $0 is BridgeAnnotation }
// we'll start our index either 1 less or 1 more, so we enter at either 0 or last element
var currentIndex = forward ? -1 : allAnnotations.count
// set our index to currentAnnotation's index if we can find it in allAnnotations
if let currentAnnotation = currentAnnotation {
if let index = allAnnotations.index(where: { (annotation) -> Bool in
return (annotation.coordinate.latitude == currentAnnotation.coordinate.latitude) &&
(annotation.coordinate.longitude == currentAnnotation.coordinate.longitude)
}) {
currentIndex = index
}
}
// now that we have our currentIndex, here's a helper to give us the next element
// the user is requesting
let nextIndex = {(index:Int) -> Int in forward ? index + 1 : index - 1}
currentIndex = nextIndex(currentIndex)
while currentIndex >= 0 && currentIndex < allAnnotations.count {
let requestedAnnotation = allAnnotations[currentIndex]
// i can't stress how important it is to have animated set to false. save yourself the 10 hours i burnt, and just go with it. if you set it to true, the map starts moving to the annotation, but there's no guarantee the annotation has an associated view yet, because it could still be animating. in which case the line below this one will be nil, and you'll have a whole bunch of annotations that can't be navigated to
self.mapView.setCenter(requestedAnnotation.coordinate, animated: false)
if let annotationView = self.mapView.view(for: requestedAnnotation) {
return UIAccessibilityCustomRotorItemResult(targetElement: annotationView, targetRange: nil)
}
currentIndex = nextIndex(currentIndex)
}
return nil
}
self.accessibilityCustomRotors = [favoritesRotor]
}