UICollectionView effective drag and drop - scroll

I am currently trying to implement the UITableView reordering behavior using UICollectionView.
Let's call a UItableView TV and a UICollectionView CV (to clarify the following explanation)
I am basically trying to reproduce the drag&drop of the TV, but I am not using the edit mode, the cell is ready to be moved as soon as the long press gesture is triggered. It works prefectly, I am using the move method of the CV, everything is fine.
I update the contentOffset property of the CV to handle the scroll when the user is dragging a cell. When a user goes to a particular rect at the top and the bottom, I update the contentOffset and the CV scroll. The problem is when the user stop moving it's finger, the gesture doesn't send any update which makes the scroll stop and start again as soon as the user moves his finger.
This behavior is definitely not natural, I would prefer continu to scroll until the user release the CV as it is the case in the TV. The TV drag&drop experience is awesome and I really want to reproduce the same feeling. Does anyone know how they manage the scroll in TV during reordering ?
I tried using a timer to trigger a scroll action repeatedly as long as the gesture position is in the right spot, the scroll was awful and not very productive (very slow and jumpy).
I also tried using GCD to listen the gesture position in another thread but the result is even worst.
I ran out of idea about that, so if someone has the answer I would marry him!
Here is the implementation of the longPress method:
- (void)handleLongPress:(UILongPressGestureRecognizer *)sender
{
ReorganizableCVCLayout *layout = (ReorganizableCVCLayout *)self.collectionView.collectionViewLayout;
CGPoint gesturePosition = [sender locationInView:self.collectionView];
NSIndexPath *selectedIndexPath = [self.collectionView indexPathForItemAtPoint:gesturePosition];
if (sender.state == UIGestureRecognizerStateBegan)
{
layout.selectedItem = selectedIndexPath;
layout.gesturePoint = gesturePosition; // Setting gesturePoint invalidate layout
}
else if (sender.state == UIGestureRecognizerStateChanged)
{
layout.gesturePoint = gesturePosition; // Setting gesturePoint invalidate layout
[self swapCellAtPoint:gesturePosition];
[self manageScrollWithReferencePoint:gesturePosition];
}
else
{
[self.collectionView performBatchUpdates:^
{
layout.selectedItem = nil;
layout.gesturePoint = CGPointZero; // Setting gesturePoint invalidate layout
} completion:^(BOOL completion){[self.collectionView reloadData];}];
}
}
To make the CV scroll, I am using that method:
- (void)manageScrollWithReferencePoint:(CGPoint)gesturePoint
{
ReorganizableCVCLayout *layout = (ReorganizableCVCLayout *)self.collectionView.collectionViewLayout;
CGFloat topScrollLimit = self.collectionView.contentOffset.y+layout.itemSize.height/2+SCROLL_BORDER;
CGFloat bottomScrollLimit = self.collectionView.contentOffset.y+self.collectionView.frame.size.height-layout.itemSize.height/2-SCROLL_BORDER;
CGPoint contentOffset = self.collectionView.contentOffset;
if (gesturePoint.y < topScrollLimit && gesturePoint.y - layout.itemSize.height/2 - SCROLL_BORDER > 0)
contentOffset.y -= SCROLL_STEP;
else if (gesturePoint.y > bottomScrollLimit &&
gesturePoint.y + layout.itemSize.height/2 + SCROLL_BORDER < self.collectionView.contentSize.height)
contentOffset.y += SCROLL_STEP;
[self.collectionView setContentOffset:contentOffset];
}

This might help
https://github.com/lxcid/LXReorderableCollectionViewFlowLayout
This is extends the UICollectionView to allow each of the UICollectionViewCells to be rearranged manually by the user with a long touch (aka touch-and-hold). The user can drag the Cell to any other position in the collection and the other cells will reorder automatically. Thanks go to lxcid for this.

Here is an alternative:
The differences between DraggableCollectionView and LXReorderableCollectionViewFlowLayout are:
The data source is only changed once. This means that while the user is dragging an item the cells are re-positioned without modifying the data source.
It's written in such a way that makes it possible to use with custom layouts.
It uses a CADisplayLink for smooth scrolling and animation.
Animations are canceled less frequently while dragging. It feels more "natural".
The protocol extends UICollectionViewDataSource with methods similar to UITableViewDataSource.
It's a work in progress. Multiple sections are now supported.
To use it with a custom layout see DraggableCollectionViewFlowLayout. Most of the logic exists in LSCollectionViewLayoutHelper. There is also an example in CircleLayoutDemo showing how to make Apple's CircleLayout example from WWDC 2012 work.

As of iOS 9, UICollectionView now supports reordering.
For UICollectionViewControllers, just override collectionView(collectionView: UICollectionView, moveItemAtIndexPath sourceIndexPath: NSIndexPath, toIndexPath destinationIndexPath: NSIndexPath)
For UICollectionViews, you'll have to handle the gestures yourself in addition to implementing the UICollectionViewDataSource method above.
Here's the code from the source:
private var longPressGesture: UILongPressGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
longPressGesture = UILongPressGestureRecognizer(target: self, action: "handleLongGesture:")
self.collectionView.addGestureRecognizer(longPressGesture)
}
func handleLongGesture(gesture: UILongPressGestureRecognizer) {
switch(gesture.state) {
case UIGestureRecognizerState.Began:
guard let selectedIndexPath = self.collectionView.indexPathForItemAtPoint(gesture.locationInView(self.collectionView)) else {
break
}
collectionView.beginInteractiveMovementForItemAtIndexPath(selectedIndexPath)
case UIGestureRecognizerState.Changed:
collectionView.updateInteractiveMovementTargetPosition(gesture.locationInView(gesture.view!))
case UIGestureRecognizerState.Ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
Sources:
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UICollectionView_class/#//apple_ref/doc/uid/TP40012177-CH1-SW67
http://nshint.io/blog/2015/07/16/uicollectionviews-now-have-easy-reordering/

If you want to experiment rolling out your own, I just wrote a Swift based tutorial you can look. I tried to build the most basic of cases so as to be easier to follow this.

Here is another approach:
Key difference is that this solution does not require a "ghost" or "dummy" cell to provide the drag and drop functionality. It simply uses the cell itself. Animations are in line with UITableView. It works by adjusting the collection view layout's private datasource while moving around. Once you let go, it will tell your controller that you can commit the change to your own datasource.
I believe it's a bit simpler to work with for most use cases. Still a work in progress, but yet another way to accomplish this. Most should find this pretty easy to incorporate into their own custom UICollectionViewLayouts.

Related

Autolayout: conditional content compression resistance priority using NSUserInterfaceCompression

I've created a custom segment control (Cocoa / macOS) by subclassing NSView (does not use any existing controls / buttons; it's an entirely custom view with a complex set of internal constraints) that has two modes:
Displays all segments horizontally by default: [ segment 1 ] [ segment 2 ] [ segment 3 ]
Displays a single segment as a drop down when all segments cannot fit in the window / current set of constraints (influenced by surrounding controls and their constraints): [ segment 1 🔽 ]
This works just fine, and I'm able to switch between / animate the two modes at runtime. However what I want to ultimately achieve is automatic expansion / compression based on the current window size (or switch between the two when the user is resizing the window). I want this control to be reusable without the window / view controller managing the switch, and trying to avoid switching between constraints based on 'rough' estimates from inside of a superview's layout call (which feels like a hack).
It seems NSSegmentControl, NSButton etc implement NSUserInterfaceCompression which should do what I am trying to achieve, however none of the methods in that protocol get called at any time during initial layout / intrinsic content size refresh / window resize etc. I also find the documentation lacking; the only useful information I found was inside the NSSegmentControl header files. The protocol seems to be exactly what I need - for the system to call the appropriate methods to determine a minimum / ideal size and ask the control to resize itself when space is at a premium.
For what it's worth, I've tried subclassing NSButton too (for various reasons, I need to stick to subclassing NSView) - however that did not trigger any of these methods either (i.e. from NSUserInterfaceCompression).
Any idea what I'm missing?
Curious... a little searching, and I can find very little information about NSUserInterfaceCompression?
Not sure what all you need to do, but something along the lines of this approach might work for you:
class SegTestView: NSView {
let segCtrl = NSSegmentedControl()
var curWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
addSubview(segCtrl)
segCtrl.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
segCtrl.topAnchor.constraint(equalTo: topAnchor),
segCtrl.leadingAnchor.constraint(equalTo: leadingAnchor),
segCtrl.trailingAnchor.constraint(equalTo: trailingAnchor),
segCtrl.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func layout() {
super.layout()
// only execute if bounds.width has changed
if curWidth != bounds.width {
curWidth = bounds.width
segCtrl.segmentCount = 3
segCtrl.setLabel("First", forSegment: 0)
segCtrl.setLabel("Second", forSegment: 1)
segCtrl.setLabel("Third", forSegment: 2)
if segCtrl.intrinsicContentSize.width > bounds.size.width {
segCtrl.segmentCount = 1
segCtrl.setLabel("Single 🔽", forSegment: 0)
} else {
// in case you want to do something else here...
}
}
}
}
It seems NSUserInterfaceCompression is a dead end. For now I've reported this as feedback / bug regarding the inadequate documentation (FB9062854).
The way I ended up solving this is by:
Setting the following content compression on the custom control:
let priorityToResistCompression = contentCompressionResistancePriority(for: .horizontal)
setContentCompressionResistancePriority(priorityToResistCompression, for: .horizontal)
The last segment (inner NSView subviews) within the control has a trailing anchor set with priority defaultLow to allow it to break so that the control can continue to stretch
Override setFrameSize and determine the best mode to display (compressed, single segment as a drop down, or all the segments if they can fit horizontally). Then call invalidateIntrinsicContentSize() for the content size to be recomputed.
Using the mode determined in the previous step, override intrinsicContentSize and offer the correct size (the minimum compressed version or the one where all segments can fit).
This way the control wraps all of this functionality into a single NSView subclass and relieves any superview / window hosting this control of setting the correct size as the window is resized.

Xcode UITest scrolling to the bottom of an UITableView

I am writing an UI test case, in which I need to perform an action, and then on the current page, scroll the only UITableView to the bottom to check if specific text shows up inside the last cell in the UITableView.
Right now the only way I can think of is to scroll it using app.tables.cells.element(boundBy: 0).swipeUp(), but if there are too many cells, it doesn't scroll all the way to the bottom. And the number of cells in the UITableView is not always the same, I cannot swipe up more than once because there might be only one cell in the table.
One way you could go about this is by getting the last cell from the tableView. Then, run a while loop that scrolls and checks to see if the cell isHittable between each scroll. Once it's determined that isHittable == true, the element can then be asserted against.
https://developer.apple.com/documentation/xctest/xcuielement/1500561-ishittable
It would look something like this (Swift answer):
In your XCTestCase file, write a query to identify the table. Then, a subsequent query to identify the last cell.
let tableView = app.descendants(matching: .table).firstMatch
guard let lastCell = tableView.cells.allElementsBoundByIndex.last else { return }
Use a while loop to determine whether or not the cell isHittable/is on screen. Note: isHittable relies on the cell's userInteractionEnabled property being set to true
//Add in a count, so that the loop can escape if it's scrolled too many times
let MAX_SCROLLS = 10
var count = 0
while lastCell.isHittable == false && count < MAX_SCROLLS {
apps.swipeUp()
count += 1
}
Check the cell's text using the label property, and compare it against the expected text.
//If there is only one label within the cell
let textInLastCell = lastCell.descendants(matching: .staticText).firstMatch
XCTAssertTrue(textInLastCell.label == "Expected Text" && textInLastCell.isHittable)
Blaines answer lead me to dig a little bit more into this topic and I found a different solution that worked for me:
func testTheTest() {
let app = XCUIApplication()
app.launch()
// Opens a menu in my app which contains the table view
app.buttons["openMenu"].tap()
// Get a handle for the tableView
let listpagetableviewTable = app.tables["myTableView"]
// Get a handle for the not yet existing cell by its content text
let cell = listpagetableviewTable.staticTexts["This text is from the cell"]
// Swipe down until it is visible
while !cell.exists {
app.swipeUp()
}
// Interact with it when visible
cell.tap()
}
One thing I had to do for this in order to work is set isAccessibilityElement to true and also assign accessibilityLabel as a String to the table view so it can be queried by it within the test code.
This might not be best practice but for what I could see in my test it works very well. I don't know how it would work when the cell has no text, one might be able to reference the cell(which is not really directly referenced here) by an image view or something else. It's obviously missing the counter from Blaines answer but I left it out for simplicity reasons.

Xcode 7 ui automation - loop through a tableview/collectionview

I am using xCode 7.1. I would like to automate interaction with all cells from a table/collection view. I would expect it to be something like this:
for i in 0..<tableView.cells.count {
let cell = collectionView.cells.elementBoundByIndex(i)
cell.tap()
backBtn.tap()
}
However this snippet only queries current descendants of the table view, so it will loop through the first m (m < n) loaded cells out of total n cells from the data source.
What is the best way to loop through all cells available in data source? Obviously querying for .Cell descendants is not the right approach.
P.S.: I tried to perform swipe on table view after every tap on cell. However it swipes to far away (scrollByOffset is not available). And again, don't know how to extract total number of cells from data source.
Cheers,
Leonid
So problem here is that you cannot call tap() on a cell that is not visible. SoI wrote a extension on XCUIElement - XCUIElement+UITableViewCell
func makeCellVisibleInWindow(window: XCUIElement, inTableView tableView: XCUIElement) {
var windowMaxY: CGFloat = CGRectGetMaxY(window.frame)
while 1 {
if self.frame.origin.y < 0 {
tableView.swipeDown()
}
else {
if self.frame.origin.y > windowMaxY {
tableView.swipeUp()
}
else {
break
}
}
}
}
Now you can use this method to make you cell visible and than tap on it.
var window: XCUIElement = application.windows.elementBoundByIndex(0)
for i in 0..<tableView.cells.count {
let cell = collectionView.cells.elementBoundByIndex(i)
cell.makeCellVisibleInWindow(window, inTableView: tableView)
cell.tap()
backBtn.tap()
}
let cells = XCUIApplication().tables.cells
for cell in cells.allElementsBoundByIndex {
cell.tap()
cell.backButton.tap()
}
I face the same situation however from my trials, you can do tap() on a cell that is not visible.
However it is not reliable and it fails for an obscur reason.
It looks to me that this is because in some situation the next cell I wanted to scroll to while parsing my table was not loaded.
So here is the trick I used:
before parsing my tables I first tap in the last cell, in my case I type an editable UITextField as all other tap will cause triggering a segue.
This first tap() cause the scroll to the last cell and so the loads of data.
then I check my cells contents
let cells = app.tables.cells
/*
this is a trick,
enter in editing for last cell of the table view so that all the cells are loaded once
avoid the next trick to fail sometime because it can't find a textField
*/
app.tables.children(matching: .cell).element(boundBy: cells.count - 1).children(matching: .textField).element(boundBy: 0).tap()
app.typeText("\r") // exit editing
for cellIdx in 0..<cells.count {
/*
this is a trick
cell may be partially or not visible, so data not loaded in table view.
Taping in it is will make it visible and so do load the data (as well as doing a scroll to the cell)
Here taping in the editable text (the name) as taping elsewhere will cause a segue to the detail view
this is why we just tap return to canel name edidting
*/
app.tables.children(matching: .cell).element(boundBy: cellIdx).children(matching: .textField).element(boundBy: 0).tap()
app.typeText("\r")
// doing my checks
}
At least so far it worked for me, not sure this is 100% working, for instance on very long list.

NSTextField controlTextDidEndEditing: called while being edited (inside an NSOutlineView)

In my NSOutlineView, I have a NSTextField inside a NSTableCellView. I am listening for the controlTextDidEndEditing: notification to happen when the user finishes the editing. However, in my case, this notification is being fired even while the user is in the middle of typing, or takes even a second-long pause in typing. This seems bizarre. I tested a NSTextField in the same view, but outside of the NSOutlineView, and it doesn't behave this way; it only calls controlTextDidEndEditing: if the user pressed the Tab or Enter keys (as expected).
Is there something I can do to prevent the NSTextField from sending controlTextDidEndEditing: unless a Enter or Tab key is pressed?
Found a solution for this:
- (void)controlTextDidEndEditing:(NSNotification *) notification {
// to prevent NSOutlineView from calling controlTextDidEndEditing by itself
if ([notification.userInfo[#"NSTextMovement"] unsignedIntegerValue]) {
....
It's an old question, but for reference, I ran into a similar problem where controlTextDidEndEditing: was called at the beginning of the editing session.
My workaround is to check if the text field still has the focus (i.e. cursor):
func controlTextDidEndEditing(_ obj: Notification) {
guard
let textField = obj.object as? NSTextField,
!textField.isFocused
else {
return
}
...
}
public extension NSTextField
{
public var isFocused:Bool {
if
window?.firstResponder is NSTextView,
let fieldEditor = window?.fieldEditor(false, for: nil),
let delegate = fieldEditor.delegate as? NSTextField,
self == delegate
{
return true
}
return false
}
}
Note to self:
I ran into this problem when adding a new item to NSOutlineView and making it editable with NSOutlineView.editColumn(row:,with:,select).
controlTextDidEndEditing() would be called right away at the start of the editing session.
It turns out it was a first responder/animation race condition. I used a NSTableView.AnimationOptions.slideDown animation when inserting the row and made the row editable afterwards.
The problem here is that the row is made editable while it is still animating. When the animation finishes, the first responder changes to the window and back to the text field, which causes controlTextDidEndEditing() to be called.
outlineView.beginUpdates()
outlineView.insertItems(at: IndexSet(integer:atIndex),
inParent: intoParent == rootItem ? nil : intoParent,
withAnimation: .slideDown) // Animating!
outlineView.endUpdates()
// Problem: the animation above won't have finished leading to first responder issues.
self.outlineView.editColumn(0, row: insertedRowIndex, with: nil, select: true)
Solution 1:
Don't use an animation when inserting the row.
Solution 2:
Wrap beginUpdates/endUpdates into an NSAnimationContext group, add a completion handler to only start editing once the animation finished.
Debugging tips:
Observe changes to firstResponder in your window controller
Put a breakpoint in controlTextDidEndEditing() and take a very close look at the stack trace to see what is causing it to be called. What gave it away in my case were references to animation calls.
To reproduce, wrap beginUpdates/endUpdates in an NSAnimationContext and increase the animation duration to a few seconds.

NSTextField with auto-suggestions like Safari's address bar?

What's the easiest way to have an NSTextField with a "recommendation list" dynamically shown below it as the user types? Just like Safari's address bar that has a menu of some sorts (I'm pretty confident Safari's address bar suggestions is menu since it has rounded corners, blue gradient selection, and background blurring).
I've tried using NSTextView's autocompletion facility but found it was inadequate:
It tries to complete words instead of the whole text fields – in other words, selecting an autocomplete suggestion will only replace the current word.
It nudges the autocompletion list forward and align it with the insertion point instead of keeping it align with the text field.
In the sample screenshot above whenever I selected the autocomplete suggestion the text field only replaces K with the suggested item in the list, which results in Abadi Abadi Kurniawan.
These are what I'd like to achieve:
Whenever a suggestion is selected, the entire text field is replaced with the suggestion.
Keep the suggestion list aligned with the text field's left side.
Note: This is not a question about adding progress indicator behind a text field.
The Safari address bar uses a separate window. Apple has example project CustomMenus and it only takes an hour or two to customize it.
Developer session explaining what has to be done Key Event Handling in Cocoa Applications
If you want to be able to select multiple words you need to provide own FieldEditor (credits should go for someone else)
- (id)windowWillReturnFieldEditor:(NSWindow *)sender toObject:(nullable id)client;
{
if ([client isKindOfClass:[NSSearchField class]])
{
if (!_mlFieldEditor)
{
_mlFieldEditor = [[MLFieldEditor alloc] init];
[_mlFieldEditor setFieldEditor:YES];
}
return _mlFieldEditor;
}
return nil;
}
- (void)insertCompletion:(NSString *)word forPartialWordRange:(NSRange)charRange movement:(NSInteger)movement isFinal:(BOOL)flag
{
// suppress completion if user types a space
if (movement == NSRightTextMovement) return;
// show full replacements
if (charRange.location != 0) {
charRange.length += charRange.location;
charRange.location = 0;
}
[super insertCompletion:word forPartialWordRange:charRange movement:movement isFinal:flag];
if (movement == NSReturnTextMovement)
{
[[NSNotificationCenter defaultCenter] postNotificationName:#"MLSearchFieldAutocompleted" object:self userInfo:nil];
}
}
This only addresses half of your answer, but I believe you need to subclass NSTextView and implement the - (NSRange)rangeForUserCompletion method, returning the range of the entire string in the text field. This should make sure that it doesn't just autocomplete the most recently entered word.
If you want a custom menu, you're going to have to do that yourself, probably by implementing the -controlTextDidChange: method and displaying a custom view with a table when appropriate.

Resources