// // QBAssetsViewController.m // QBImagePicker // // Created by Katsuma Tanaka on 2015/04/03. // Copyright (c) 2015 Katsuma Tanaka. All rights reserved. // #import "QBAssetsViewController.h" #import // Views #import "QBImagePickerController.h" #import "QBAssetCell.h" #import "QBVideoIndicatorView.h" static CGSize CGSizeScale(CGSize size, CGFloat scale) { return CGSizeMake(size.width * scale, size.height * scale); } @interface QBImagePickerController (Private) @property (nonatomic, strong) NSBundle *assetBundle; @end @implementation NSIndexSet (Convenience) - (NSArray *)qb_indexPathsFromIndexesWithSection:(NSUInteger)section { NSMutableArray *indexPaths = [NSMutableArray arrayWithCapacity:self.count]; [self enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) { [indexPaths addObject:[NSIndexPath indexPathForItem:idx inSection:section]]; }]; return indexPaths; } @end @implementation UICollectionView (Convenience) - (NSArray *)qb_indexPathsForElementsInRect:(CGRect)rect { NSArray *allLayoutAttributes = [self.collectionViewLayout layoutAttributesForElementsInRect:rect]; if (allLayoutAttributes.count == 0) { return nil; } NSMutableArray *indexPaths = [NSMutableArray arrayWithCapacity:allLayoutAttributes.count]; for (UICollectionViewLayoutAttributes *layoutAttributes in allLayoutAttributes) { NSIndexPath *indexPath = layoutAttributes.indexPath; [indexPaths addObject:indexPath]; } return indexPaths; } @end @interface QBAssetsViewController () @property (nonatomic, strong) IBOutlet UIBarButtonItem *doneButton; @property (nonatomic, strong) PHFetchResult *fetchResult; @property (nonatomic, strong) PHCachingImageManager *imageManager; @property (nonatomic, assign) CGRect previousPreheatRect; @property (nonatomic, assign) BOOL disableScrollToBottom; @property (nonatomic, strong) NSIndexPath *lastSelectedItemIndexPath; @end @implementation QBAssetsViewController - (void)viewDidLoad { [super viewDidLoad]; [self setUpToolbarItems]; [self resetCachedAssets]; // Register observer [[PHPhotoLibrary sharedPhotoLibrary] registerChangeObserver:self]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Configure navigation item self.navigationItem.title = self.assetCollection.localizedTitle; self.navigationItem.prompt = self.imagePickerController.prompt; // Configure collection view self.collectionView.allowsMultipleSelection = self.imagePickerController.allowsMultipleSelection; // Show/hide 'Done' button if (self.imagePickerController.allowsMultipleSelection) { [self.navigationItem setRightBarButtonItem:self.doneButton animated:NO]; } else { [self.navigationItem setRightBarButtonItem:nil animated:NO]; } [self updateDoneButtonState]; [self updateSelectionInfo]; [self.collectionView reloadData]; // Scroll to bottom if (self.fetchResult.count > 0 && self.isMovingToParentViewController && !self.disableScrollToBottom) { NSIndexPath *indexPath = [NSIndexPath indexPathForItem:(self.fetchResult.count - 1) inSection:0]; [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionTop animated:NO]; } } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; self.disableScrollToBottom = YES; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; self.disableScrollToBottom = NO; [self updateCachedAssets]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { // Save indexPath for the last item NSIndexPath *indexPath = [[self.collectionView indexPathsForVisibleItems] lastObject]; // Update layout [self.collectionViewLayout invalidateLayout]; // Restore scroll position [coordinator animateAlongsideTransition:nil completion:^(id context) { [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; }]; } - (void)dealloc { // Deregister observer [[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:self]; } #pragma mark - Accessors - (void)setAssetCollection:(PHAssetCollection *)assetCollection { _assetCollection = assetCollection; [self updateFetchRequest]; [self.collectionView reloadData]; } - (PHCachingImageManager *)imageManager { if (_imageManager == nil) { _imageManager = [PHCachingImageManager new]; } return _imageManager; } - (BOOL)isAutoDeselectEnabled { return (self.imagePickerController.maximumNumberOfSelection == 1 && self.imagePickerController.maximumNumberOfSelection >= self.imagePickerController.minimumNumberOfSelection); } #pragma mark - Actions - (IBAction)done:(id)sender { if ([self.imagePickerController.delegate respondsToSelector:@selector(qb_imagePickerController:didFinishPickingAssets:)]) { [self.imagePickerController.delegate qb_imagePickerController:self.imagePickerController didFinishPickingAssets:self.imagePickerController.selectedAssets.array]; } } #pragma mark - Toolbar - (void)setUpToolbarItems { // Space UIBarButtonItem *leftSpace = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:NULL]; UIBarButtonItem *rightSpace = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:NULL]; // Info label NSDictionary *attributes = @{ NSForegroundColorAttributeName: [UIColor blackColor] }; UIBarButtonItem *infoButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:NULL]; infoButtonItem.enabled = NO; [infoButtonItem setTitleTextAttributes:attributes forState:UIControlStateNormal]; [infoButtonItem setTitleTextAttributes:attributes forState:UIControlStateDisabled]; self.toolbarItems = @[leftSpace, infoButtonItem, rightSpace]; } - (void)updateSelectionInfo { NSMutableOrderedSet *selectedAssets = self.imagePickerController.selectedAssets; if (selectedAssets.count > 0) { NSBundle *bundle = self.imagePickerController.assetBundle; NSString *format; if (selectedAssets.count > 1) { format = NSLocalizedStringFromTableInBundle(@"assets.toolbar.items-selected", @"QBImagePicker", bundle, nil); } else { format = NSLocalizedStringFromTableInBundle(@"assets.toolbar.item-selected", @"QBImagePicker", bundle, nil); } NSString *title = [NSString stringWithFormat:format, selectedAssets.count]; [(UIBarButtonItem *)self.toolbarItems[1] setTitle:title]; } else { [(UIBarButtonItem *)self.toolbarItems[1] setTitle:@""]; } } #pragma mark - Fetching Assets - (void)updateFetchRequest { if (self.assetCollection) { PHFetchOptions *options = [PHFetchOptions new]; switch (self.imagePickerController.mediaType) { case QBImagePickerMediaTypeImage: options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %ld", PHAssetMediaTypeImage]; break; case QBImagePickerMediaTypeVideo: options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %ld", PHAssetMediaTypeVideo]; break; default: break; } self.fetchResult = [PHAsset fetchAssetsInAssetCollection:self.assetCollection options:options]; if ([self isAutoDeselectEnabled] && self.imagePickerController.selectedAssets.count > 0) { // Get index of previous selected asset PHAsset *asset = [self.imagePickerController.selectedAssets firstObject]; NSInteger assetIndex = [self.fetchResult indexOfObject:asset]; self.lastSelectedItemIndexPath = [NSIndexPath indexPathForItem:assetIndex inSection:0]; } } else { self.fetchResult = nil; } } #pragma mark - Checking for Selection Limit - (BOOL)isMinimumSelectionLimitFulfilled { return (self.imagePickerController.minimumNumberOfSelection <= self.imagePickerController.selectedAssets.count); } - (BOOL)isMaximumSelectionLimitReached { NSUInteger minimumNumberOfSelection = MAX(1, self.imagePickerController.minimumNumberOfSelection); if (minimumNumberOfSelection <= self.imagePickerController.maximumNumberOfSelection) { return (self.imagePickerController.maximumNumberOfSelection <= self.imagePickerController.selectedAssets.count); } return NO; } - (void)updateDoneButtonState { self.doneButton.enabled = [self isMinimumSelectionLimitFulfilled]; } #pragma mark - Asset Caching - (void)resetCachedAssets { [self.imageManager stopCachingImagesForAllAssets]; self.previousPreheatRect = CGRectZero; } - (void)updateCachedAssets { BOOL isViewVisible = [self isViewLoaded] && self.view.window != nil; if (!isViewVisible) { return; } // The preheat window is twice the height of the visible rect CGRect preheatRect = self.collectionView.bounds; preheatRect = CGRectInset(preheatRect, 0.0, -0.5 * CGRectGetHeight(preheatRect)); // If scrolled by a "reasonable" amount... CGFloat delta = ABS(CGRectGetMidY(preheatRect) - CGRectGetMidY(self.previousPreheatRect)); if (delta > CGRectGetHeight(self.collectionView.bounds) / 3.0) { // Compute the assets to start caching and to stop caching NSMutableArray *addedIndexPaths = [NSMutableArray array]; NSMutableArray *removedIndexPaths = [NSMutableArray array]; [self computeDifferenceBetweenRect:self.previousPreheatRect andRect:preheatRect addedHandler:^(CGRect addedRect) { NSArray *indexPaths = [self.collectionView qb_indexPathsForElementsInRect:addedRect]; [addedIndexPaths addObjectsFromArray:indexPaths]; } removedHandler:^(CGRect removedRect) { NSArray *indexPaths = [self.collectionView qb_indexPathsForElementsInRect:removedRect]; [removedIndexPaths addObjectsFromArray:indexPaths]; }]; NSArray *assetsToStartCaching = [self assetsAtIndexPaths:addedIndexPaths]; NSArray *assetsToStopCaching = [self assetsAtIndexPaths:removedIndexPaths]; CGSize itemSize = [(UICollectionViewFlowLayout *)self.collectionViewLayout itemSize]; CGSize targetSize = CGSizeScale(itemSize, self.traitCollection.displayScale); [self.imageManager startCachingImagesForAssets:assetsToStartCaching targetSize:targetSize contentMode:PHImageContentModeAspectFill options:nil]; [self.imageManager stopCachingImagesForAssets:assetsToStopCaching targetSize:targetSize contentMode:PHImageContentModeAspectFill options:nil]; self.previousPreheatRect = preheatRect; } } - (void)computeDifferenceBetweenRect:(CGRect)oldRect andRect:(CGRect)newRect addedHandler:(void (^)(CGRect addedRect))addedHandler removedHandler:(void (^)(CGRect removedRect))removedHandler { if (CGRectIntersectsRect(newRect, oldRect)) { CGFloat oldMaxY = CGRectGetMaxY(oldRect); CGFloat oldMinY = CGRectGetMinY(oldRect); CGFloat newMaxY = CGRectGetMaxY(newRect); CGFloat newMinY = CGRectGetMinY(newRect); if (newMaxY > oldMaxY) { CGRect rectToAdd = CGRectMake(newRect.origin.x, oldMaxY, newRect.size.width, (newMaxY - oldMaxY)); addedHandler(rectToAdd); } if (oldMinY > newMinY) { CGRect rectToAdd = CGRectMake(newRect.origin.x, newMinY, newRect.size.width, (oldMinY - newMinY)); addedHandler(rectToAdd); } if (newMaxY < oldMaxY) { CGRect rectToRemove = CGRectMake(newRect.origin.x, newMaxY, newRect.size.width, (oldMaxY - newMaxY)); removedHandler(rectToRemove); } if (oldMinY < newMinY) { CGRect rectToRemove = CGRectMake(newRect.origin.x, oldMinY, newRect.size.width, (newMinY - oldMinY)); removedHandler(rectToRemove); } } else { addedHandler(newRect); removedHandler(oldRect); } } - (NSArray *)assetsAtIndexPaths:(NSArray *)indexPaths { if (indexPaths.count == 0) { return nil; } NSMutableArray *assets = [NSMutableArray arrayWithCapacity:indexPaths.count]; for (NSIndexPath *indexPath in indexPaths) { if (indexPath.item < self.fetchResult.count) { PHAsset *asset = self.fetchResult[indexPath.item]; [assets addObject:asset]; } } return assets; } #pragma mark - PHPhotoLibraryChangeObserver - (void)photoLibraryDidChange:(PHChange *)changeInstance { dispatch_async(dispatch_get_main_queue(), ^{ PHFetchResultChangeDetails *collectionChanges = [changeInstance changeDetailsForFetchResult:self.fetchResult]; if (collectionChanges) { // Get the new fetch result self.fetchResult = [collectionChanges fetchResultAfterChanges]; if (![collectionChanges hasIncrementalChanges] || [collectionChanges hasMoves]) { // We need to reload all if the incremental diffs are not available [self.collectionView reloadData]; } else { // If we have incremental diffs, tell the collection view to animate insertions and deletions [self.collectionView performBatchUpdates:^{ NSIndexSet *removedIndexes = [collectionChanges removedIndexes]; if ([removedIndexes count]) { [self.collectionView deleteItemsAtIndexPaths:[removedIndexes qb_indexPathsFromIndexesWithSection:0]]; } NSIndexSet *insertedIndexes = [collectionChanges insertedIndexes]; if ([insertedIndexes count]) { [self.collectionView insertItemsAtIndexPaths:[insertedIndexes qb_indexPathsFromIndexesWithSection:0]]; } NSIndexSet *changedIndexes = [collectionChanges changedIndexes]; if ([changedIndexes count]) { [self.collectionView reloadItemsAtIndexPaths:[changedIndexes qb_indexPathsFromIndexesWithSection:0]]; } } completion:NULL]; } [self resetCachedAssets]; } }); } #pragma mark - UIScrollViewDelegate - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self updateCachedAssets]; } #pragma mark - UICollectionViewDataSource - (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView { return 1; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return self.fetchResult.count; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { QBAssetCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"AssetCell" forIndexPath:indexPath]; cell.tag = indexPath.item; cell.showsOverlayViewWhenSelected = self.imagePickerController.allowsMultipleSelection; // Image PHAsset *asset = self.fetchResult[indexPath.item]; CGSize itemSize = [(UICollectionViewFlowLayout *)collectionView.collectionViewLayout itemSize]; CGSize targetSize = CGSizeScale(itemSize, self.traitCollection.displayScale); [self.imageManager requestImageForAsset:asset targetSize:targetSize contentMode:PHImageContentModeAspectFill options:nil resultHandler:^(UIImage *result, NSDictionary *info) { if (cell.tag == indexPath.item) { cell.imageView.image = result; } }]; // Video indicator if (asset.mediaType == PHAssetMediaTypeVideo) { cell.videoIndicatorView.hidden = NO; NSInteger minutes = (NSInteger)(asset.duration / 60.0); NSInteger seconds = (NSInteger)ceil(asset.duration - 60.0 * (double)minutes); cell.videoIndicatorView.timeLabel.text = [NSString stringWithFormat:@"%02ld:%02ld", (long)minutes, (long)seconds]; if (asset.mediaSubtypes & PHAssetMediaSubtypeVideoHighFrameRate) { cell.videoIndicatorView.videoIcon.hidden = YES; cell.videoIndicatorView.slomoIcon.hidden = NO; } else { cell.videoIndicatorView.videoIcon.hidden = NO; cell.videoIndicatorView.slomoIcon.hidden = YES; } } else { cell.videoIndicatorView.hidden = YES; } // Selection state if ([self.imagePickerController.selectedAssets containsObject:asset]) { [cell setSelected:YES]; [collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; } return cell; } - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath { if (kind == UICollectionElementKindSectionFooter) { UICollectionReusableView *footerView = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"FooterView" forIndexPath:indexPath]; // Number of assets UILabel *label = (UILabel *)[footerView viewWithTag:1]; NSBundle *bundle = self.imagePickerController.assetBundle; NSUInteger numberOfPhotos = [self.fetchResult countOfAssetsWithMediaType:PHAssetMediaTypeImage]; NSUInteger numberOfVideos = [self.fetchResult countOfAssetsWithMediaType:PHAssetMediaTypeVideo]; switch (self.imagePickerController.mediaType) { case QBImagePickerMediaTypeAny: { NSString *format; if (numberOfPhotos == 1) { if (numberOfVideos == 1) { format = NSLocalizedStringFromTableInBundle(@"assets.footer.photo-and-video", @"QBImagePicker", bundle, nil); } else { format = NSLocalizedStringFromTableInBundle(@"assets.footer.photo-and-videos", @"QBImagePicker", bundle, nil); } } else if (numberOfVideos == 1) { format = NSLocalizedStringFromTableInBundle(@"assets.footer.photos-and-video", @"QBImagePicker", bundle, nil); } else { format = NSLocalizedStringFromTableInBundle(@"assets.footer.photos-and-videos", @"QBImagePicker", bundle, nil); } label.text = [NSString stringWithFormat:format, numberOfPhotos, numberOfVideos]; } break; case QBImagePickerMediaTypeImage: { NSString *key = (numberOfPhotos == 1) ? @"assets.footer.photo" : @"assets.footer.photos"; NSString *format = NSLocalizedStringFromTableInBundle(key, @"QBImagePicker", bundle, nil); label.text = [NSString stringWithFormat:format, numberOfPhotos]; } break; case QBImagePickerMediaTypeVideo: { NSString *key = (numberOfVideos == 1) ? @"assets.footer.video" : @"assets.footer.videos"; NSString *format = NSLocalizedStringFromTableInBundle(key, @"QBImagePicker", bundle, nil); label.text = [NSString stringWithFormat:format, numberOfVideos]; } break; } return footerView; } return nil; } #pragma mark - UICollectionViewDelegate - (BOOL)collectionView:(UICollectionView *)collectionView shouldSelectItemAtIndexPath:(NSIndexPath *)indexPath { if ([self.imagePickerController.delegate respondsToSelector:@selector(qb_imagePickerController:shouldSelectAsset:)]) { PHAsset *asset = self.fetchResult[indexPath.item]; return [self.imagePickerController.delegate qb_imagePickerController:self.imagePickerController shouldSelectAsset:asset]; } if ([self isAutoDeselectEnabled]) { return YES; } return ![self isMaximumSelectionLimitReached]; } - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { QBImagePickerController *imagePickerController = self.imagePickerController; NSMutableOrderedSet *selectedAssets = imagePickerController.selectedAssets; PHAsset *asset = self.fetchResult[indexPath.item]; if (imagePickerController.allowsMultipleSelection) { if ([self isAutoDeselectEnabled] && selectedAssets.count > 0) { // Remove previous selected asset from set [selectedAssets removeObjectAtIndex:0]; // Deselect previous selected asset if (self.lastSelectedItemIndexPath) { [collectionView deselectItemAtIndexPath:self.lastSelectedItemIndexPath animated:NO]; } } // Add asset to set [selectedAssets addObject:asset]; self.lastSelectedItemIndexPath = indexPath; [self updateDoneButtonState]; if (imagePickerController.showsNumberOfSelectedAssets) { [self updateSelectionInfo]; if (selectedAssets.count == 1) { // Show toolbar [self.navigationController setToolbarHidden:NO animated:YES]; } } } else { if ([imagePickerController.delegate respondsToSelector:@selector(qb_imagePickerController:didFinishPickingAssets:)]) { [imagePickerController.delegate qb_imagePickerController:imagePickerController didFinishPickingAssets:@[asset]]; } } if ([imagePickerController.delegate respondsToSelector:@selector(qb_imagePickerController:didSelectAsset:)]) { [imagePickerController.delegate qb_imagePickerController:imagePickerController didSelectAsset:asset]; } } - (void)collectionView:(UICollectionView *)collectionView didDeselectItemAtIndexPath:(NSIndexPath *)indexPath { if (!self.imagePickerController.allowsMultipleSelection) { return; } QBImagePickerController *imagePickerController = self.imagePickerController; NSMutableOrderedSet *selectedAssets = imagePickerController.selectedAssets; PHAsset *asset = self.fetchResult[indexPath.item]; // Remove asset from set [selectedAssets removeObject:asset]; self.lastSelectedItemIndexPath = nil; [self updateDoneButtonState]; if (imagePickerController.showsNumberOfSelectedAssets) { [self updateSelectionInfo]; if (selectedAssets.count == 0) { // Hide toolbar [self.navigationController setToolbarHidden:YES animated:YES]; } } if ([imagePickerController.delegate respondsToSelector:@selector(qb_imagePickerController:didDeselectAsset:)]) { [imagePickerController.delegate qb_imagePickerController:imagePickerController didDeselectAsset:asset]; } } #pragma mark - UICollectionViewDelegateFlowLayout - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { NSUInteger numberOfColumns; if (UIInterfaceOrientationIsPortrait([[UIApplication sharedApplication] statusBarOrientation])) { numberOfColumns = self.imagePickerController.numberOfColumnsInPortrait; } else { numberOfColumns = self.imagePickerController.numberOfColumnsInLandscape; } CGFloat width = (CGRectGetWidth(self.view.frame) - 2.0 * (numberOfColumns - 1)) / numberOfColumns; return CGSizeMake(width, width); } @end