Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Provide support for context menus #1

Merged
merged 8 commits into from
Sep 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag

### Enhancements

- Added support for iOS 13 Context Menus with `contextMenuConfigurationForItemAt` method. [Jérôme B.](https://github.com/jjbourdev) [(#1430)](https://github.com/Instagram/IGListKit/pull/1430).

- Added `shouldSelectItemAtIndex:` to `IGListSectionController` . [dirtmelon](https://github.com/dirtmelon)

- Added [Mac Catalyst](https://developer.apple.com/mac-catalyst/) support. [Petro Rovenskyy](https://github.com/3a4oT/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,26 @@ final class MonthSectionController: ListBindingSectionController<ListDiffable>,

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, didUnhighlightItemAt index: Int, viewModel: Any) {}

@available(iOS 13.0, *)
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, contextMenuConfigurationForItemAt index: Int, point: CGPoint, viewModel: Any) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
// Create an action for sharing
let share = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in
// Show share sheet
}

// Create an action for copy
let rename = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
// Perform copy
}

// Create an action for delete with destructive attributes (highligh in red)
let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
// Perform delete
}

// Create a UIMenu with all the actions as children
return UIMenu(title: "", children: [share, rename, delete])
}
}
}
34 changes: 34 additions & 0 deletions Source/IGListKit/IGListSectionController.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,40 @@ NS_SWIFT_NAME(ListSectionController)
*/
- (void)didUnhighlightItemAtIndex:(NSInteger)index;

/**
Tells the section controller that the cell has requested a menu configuration.

@param index The index of the cell that requested the menu.
@param point The point of the tap on the cell.

@return An object that conforms to `UIContextMenuConfiguration`

@note The default implementation does nothing. **Calling super is not required.**
*/
- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Tells the section controller that the cell has requested a preview for context menu highlight.

@param configuration Context menu configuration.

@return An object that conforms to `UITargetedPreview`

@note The default implementation does nothing. **Calling super is not required.**
*/
- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Tells the section controller that the cell has requested a preview for context menu dismiss.

@param configuration Context menu configuration.

@return An object that conforms to `UITargetedPreview`

@note The default implementation does nothing. **Calling super is not required.**
*/
- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Identifies whether an object can be moved through interactive reordering.

Expand Down
12 changes: 12 additions & 0 deletions Source/IGListKit/IGListSectionController.m
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ - (void)didHighlightItemAtIndex:(NSInteger)index {}

- (void)didUnhighlightItemAtIndex:(NSInteger)index {}

- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point {
return nil;
}

- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration {
return nil;
}

- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration {
return nil;
}

- (BOOL)canMoveItemAtIndex:(NSInteger)index {
return NO;
}
Expand Down
47 changes: 47 additions & 0 deletions Source/IGListKit/Internal/IGListAdapter+UICollectionView.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

#import "IGListAdapterInternal.h"

@interface IGListAdapter ()

@property (nonatomic, strong) NSMapTable *configurationToSectionController;

@end

@implementation IGListAdapter (UICollectionView)

#pragma mark - UICollectionViewDataSource
Expand Down Expand Up @@ -269,6 +275,47 @@ - (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIn
[sectionController didUnhighlightItemAtIndex:indexPath.item];
}

#if !TARGET_OS_TV
- (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionView contextMenuConfigurationForItemAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) {
// forward this method to the delegate b/c this implementation will steal the message from the proxy
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:)]) {
return [collectionViewDelegate collectionView:collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:point];
}

IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section];

UIContextMenuConfiguration *configuration = [sectionController contextMenuConfigurationForItemAtIndex:indexPath.item point:point];
if (configuration) {
if (!self.configurationToSectionController) {
self.configurationToSectionController = [NSMapTable weakToWeakObjectsMapTable];
}
[self.configurationToSectionController setObject:configuration forKey:sectionController];
}
return nil;
}

- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForHighlightingContextMenuWithConfiguration:)]) {
return [collectionViewDelegate collectionView:collectionView previewForHighlightingContextMenuWithConfiguration:configuration];
}

IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration];
return [sectionController previewForHighlightingContextMenuWithConfiguration:configuration];
}

- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForDismissingContextMenuWithConfiguration:)]) {
return [collectionViewDelegate collectionView:collectionView previewForDismissingContextMenuWithConfiguration:configuration];
}

IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration];
return [sectionController previewForDismissingContextMenuWithConfiguration:configuration];
}
#endif

#pragma mark - UICollectionViewDelegateFlowLayout

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
Expand Down
3 changes: 3 additions & 0 deletions Source/IGListKit/Internal/IGListAdapterProxy.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ static BOOL isInterceptedSelector(SEL sel) {
sel == @selector(collectionView:didDeselectItemAtIndexPath:) ||
sel == @selector(collectionView:didHighlightItemAtIndexPath:) ||
sel == @selector(collectionView:didUnhighlightItemAtIndexPath:) ||
sel == @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:) ||
sel == @selector(collectionView:previewForHighlightingContextMenuWithConfiguration:) ||
sel == @selector(collectionView:previewForDismissingContextMenuWithConfiguration:) ||
// UICollectionViewDelegateFlowLayout
sel == @selector(collectionView:layout:sizeForItemAtIndexPath:) ||
sel == @selector(collectionView:layout:insetForSectionAtIndex:) ||
Expand Down
36 changes: 35 additions & 1 deletion Tests/IGListAdapterTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,7 @@ - (void)test_whenUnhighlightingCell_thatCollectionViewDelegateReceivesMethod {
[mockDelegate verify];
}

- (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod {
- (void)test_whenUnhighlightingCell_thatSectionControllerReceivesMethod {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

Expand All @@ -1357,6 +1357,40 @@ - (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod {
XCTAssertFalse(s2.wasUnhighlighted);
}

- (void)test_whenContextMenuAskedCell_thatCollectionViewDelegateReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

id mockDelegate = [OCMockObject mockForProtocol:@protocol(UICollectionViewDelegate)];
self.adapter.collectionViewDelegate = mockDelegate;

NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
[[mockDelegate expect] collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

// simulates the collectionview telling its delegate that it needs the context menu configuration
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

[mockDelegate verify];
}

- (void)test_whenContextMenuAskedCell_thatSectionControllerReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];

// simulates the collectionview telling its delegate that it needs the context menu configuration
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

IGListTestSection *s0 = [self.adapter sectionControllerForObject:@0];
IGListTestSection *s1 = [self.adapter sectionControllerForObject:@1];
IGListTestSection *s2 = [self.adapter sectionControllerForObject:@2];

XCTAssertTrue(s0.requestedContextMenu);
XCTAssertFalse(s1.requestedContextMenu);
XCTAssertFalse(s2.requestedContextMenu);
}

- (void)test_whenDataSourceDoesntHandleObject_thatObjectIsDropped {
// IGListTestAdapterDataSource does not handle NSStrings
self.dataSource.objects = @[@1, @"dog", @2];
Expand Down
9 changes: 9 additions & 0 deletions Tests/IGListBindingSectionControllerTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ - (void)test_whenUnhighlightingCell_thatCorrectViewModelUnhighlighted {
XCTAssertEqualObjects(section.unhighlightedViewModel, @"seven");
}

- (void)test_whenContextMenuAskedCell_thatCorrectViewModelRetrieved API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
[self setupWithObjects:@[
[[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]],
]];
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0] point:CGPointZero];
IGTestDiffingSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject];
XCTAssertEqualObjects(section.contextMenuViewModel, @"seven");
}

- (void)test_whenDeselectingCell_withoutImplementation_thatNoOps {
[self setupWithObjects:@[
[[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]],
Expand Down
1 change: 1 addition & 0 deletions Tests/Objects/IGListTestSection.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
@property (nonatomic, assign) BOOL wasHighlighted;
@property (nonatomic, assign) BOOL wasUnhighlighted;
@property (nonatomic, assign) BOOL wasDisplayed;
@property (nonatomic, assign) BOOL requestedContextMenu;

@end
5 changes: 5 additions & 0 deletions Tests/Objects/IGListTestSection.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ - (void)didUnhighlightItemAtIndex:(NSInteger)index {
self.wasUnhighlighted = YES;
}

- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point {
self.requestedContextMenu = YES;
return nil;
}

#pragma mark - IGListDisplayDelegate

- (void)listAdapter:(IGListAdapter *)listAdapter willDisplaySectionController:(IGListSectionController *)sectionController {
Expand Down
8 changes: 8 additions & 0 deletions Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ - (void)sectionController:(nonnull IGListBindingSectionController *)sectionContr
viewModel:(nonnull id)viewModel {
}


- (UIContextMenuConfiguration * _Nullable)sectionController:(nonnull IGListBindingSectionController *)sectionController
contextMenuConfigurationForItemAtIndex:(NSInteger)index
point:(CGPoint)point
viewModel:(nonnull id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
return nil;
}

@end
1 change: 1 addition & 0 deletions Tests/Objects/IGTestDiffingSectionController.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
@property (nonatomic, strong) id deselectedViewModel;
@property (nonatomic, strong) id highlightedViewModel;
@property (nonatomic, strong) id unhighlightedViewModel;
@property (nonatomic, strong) id contextMenuViewModel;

@end
5 changes: 5 additions & 0 deletions Tests/Objects/IGTestDiffingSectionController.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ - (void)sectionController:(IGListBindingSectionController *)sectionController di
self.unhighlightedViewModel = viewModel;
}

- (UIContextMenuConfiguration * _Nullable)sectionController:(IGListBindingSectionController *)sectionController contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point viewModel:(id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.contextMenuViewModel = viewModel;
return nil;
}

@end