Retired Document
Important: This document may not represent best practices for current development. Links to downloads and other resources may no longer be valid.
CloudPhotos (iOS)/CloudPhotos/APLMainTableViewController.m
/* |
Copyright (C) 2017 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
The application's primary table view controller showing the list of photos. |
*/ |
#import "APLMainTableViewController.h" |
#import "AppDelegate.h" |
#import "APLDetailTableViewController.h" |
#import "APLCloudManager.h" |
#import "PhotoTableCell.h" |
#import "CloudPhoto.h" |
@import CloudKit; |
@import CoreLocation; // for tracking user's location and CLGeocoder |
@import Photos; |
static NSString * const kCellIdentifier = @"cellID"; |
typedef NS_ENUM(NSInteger, ScopeIndexes) { |
kAllScope = 0, |
kMineScope, |
kRecentScope, |
kNearMeScope |
}; |
#define kRecentDays 5 // number of days we consider a photo to be recent |
#pragma mark - |
@interface APLMainTableViewController () <UISearchBarDelegate, UISearchControllerDelegate, UISearchResultsUpdating, CLLocationManagerDelegate> |
@property (nonatomic, strong) NSMutableArray *photos; // all photos |
@property (nonatomic, strong) NSMutableArray *searchedPhotos; // all photos found based on the search bar's scope |
@property (nonatomic, strong) NSArray *filteredPhotos; // photos currently being filtered in and out while typing in the search bar |
@property (nonatomic, strong) UISearchController *searchController; |
@property (nonatomic, weak) IBOutlet UIBarButtonItem *addButton; |
// no photos label and its constraints, added to the table if no photos are present |
@property (nonatomic, strong) UILabel *noPhotosLabel; |
@property (nonatomic, strong) NSLayoutConstraint *labelConstraintForX; |
@property (nonatomic, strong) NSLayoutConstraint *labelConstraintForY; |
@property (assign) BOOL wasResumed; // keep track when we were re-activated from the background |
// for state restoration |
@property BOOL restoringSearchState; |
@property BOOL searchControllerWasActive; |
@property BOOL searchControllerSearchFieldWasFirstResponder; |
@property (nonatomic, strong) NSString *searchControllerText; |
@property (assign) NSInteger searchControllerScopeIndex; |
@property BOOL searchControllerActiveFromPreviousView; |
// for tracking user location |
@property (nonatomic, strong) CLLocation *currentLocation; |
@property (nonatomic, strong) CLLocationManager *locationManager; |
@property (nonatomic, strong) CLGeocoder *geocoder; |
@end |
#pragma mark - |
@implementation APLMainTableViewController |
- (void)viewDidLoad |
{ |
[super viewDidLoad]; |
self.tableView.dataSource = nil; // we aren't yet ready to populate our photo list |
_geocoder = [[CLGeocoder alloc] init]; // so we can show the user the city and state a given photo was taken |
// location services |
_locationManager = [[CLLocationManager alloc] init]; |
self.locationManager.delegate = self; |
self.locationManager.distanceFilter = kCLDistanceFilterNone; |
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest; |
if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) |
{ |
[self.locationManager requestWhenInUseAuthorization]; // ask for user permission to find our location, we use this to find photos near us |
} |
// setup our search display controller for searching photos |
// |
_searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; |
self.searchController.searchResultsUpdater = self; |
[self.searchController.searchBar sizeToFit]; |
self.tableView.tableHeaderView = self.searchController.searchBar; |
self.searchController.delegate = self; |
self.searchController.dimsBackgroundDuringPresentation = NO; // default is YES |
self.searchController.searchBar.delegate = self; // so we can monitor text changes + others |
self.searchController.searchBar.autocapitalizationType = UITextAutocapitalizationTypeNone; |
// Search is now just presenting a view controller. As such, normal view controller |
// presentation semantics apply. Namely that presentation will walk up the view controller |
// hierarchy until it finds the root view controller or one that defines a presentation context. |
// |
self.definesPresentationContext = YES; // know where you want UISearchController to be displayed |
// create our refresh control so users can rescan for photos |
UIRefreshControl *refreshControl = [[UIRefreshControl alloc] initWithFrame:CGRectZero]; |
[refreshControl addTarget:self action:@selector(refresh:) forControlEvents:UIControlEventValueChanged]; |
self.refreshControl = refreshControl; |
// while in table edit mode, we allow for "Delete My Photos" feature in the bottom toolbar |
UIBarButtonItem *clearAllButton = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Delete My Photos", nil) |
style:UIBarButtonItemStylePlain |
target:self |
action:@selector(deleteAllAction:)]; |
[self setToolbarItems:@[clearAllButton] animated:NO]; |
// listen when we are backgrounded (leaving the app) |
[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification |
object:nil |
queue:nil |
usingBlock:^(NSNotification *notification) { |
// we are being backgrounded, exit edit mode in our table |
if (self.isEditing) |
{ |
[self setEditing:NO animated:NO]; |
} |
}]; |
// listen when we are activated (resuming the app) |
[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification |
object:nil |
queue:nil |
usingBlock:^(NSNotification *notification) { |
_wasResumed = YES; |
}]; |
// listen for updates due to push notification processing, so we can update our UI |
[[NSNotificationCenter defaultCenter] addObserverForName:[APLCloudManager UpdateContentWithNotification] |
object:nil |
queue:nil |
usingBlock:^(NSNotification *notification) { |
// a push notification (CKQueryNotification) has arrived, |
// update our table for added, removed or updates photos |
// |
CKQueryNotification *queryNotification = notification.object; |
CKQueryNotificationReason reason = queryNotification.queryNotificationReason; |
CKRecordID *recordID = queryNotification.recordID; |
// a photo has come in that was added, deleted or updated: |
// update just the table cell this CKRecord is associated with, |
// instead of just doing an entire table re-fetch, let's be efficient and just apply the update for the photo in question |
// |
[self updateTableWithRecordID:recordID reason:reason]; |
}]; |
// initially add our right Edit button as disabled |
self.navigationItem.rightBarButtonItem = self.editButtonItem; |
self.editButtonItem.enabled = NO; |
// create a custom navigation bar button and clear it's title (so it's only a back arrow) |
// we do this to allow for more room for the photo title in the center |
// |
UIBarButtonItem *backBarButtonItem = [[UIBarButtonItem alloc] init]; |
backBarButtonItem.title = @""; |
self.navigationItem.backBarButtonItem = backBarButtonItem; |
// then search for photos |
[self refresh:self]; |
} |
- (void)viewDidAppear:(BOOL)animated |
{ |
[super viewDidAppear:animated]; |
// restore the searchController's active state |
if (self.searchControllerWasActive) |
{ |
// filtering the list of photos isn't possible yet until our fetch completes in "loadPhotos", |
// so filter the table after the fetch completes with this flag |
// |
_restoringSearchState = YES; |
// restore our search controller using state restoration |
self.searchController.active = self.searchControllerWasActive; |
_searchControllerWasActive = NO; // reset this state for next time |
self.searchController.searchBar.text = self.searchControllerText; |
// only restore scopes except "near me", because we may not have the user's location captured yet |
self.searchController.searchBar.selectedScopeButtonIndex = self.searchControllerScopeIndex; |
if (self.searchControllerSearchFieldWasFirstResponder) |
{ |
[self.searchController.searchBar becomeFirstResponder]; |
_searchControllerSearchFieldWasFirstResponder = NO; // reset this state for next time |
} |
} |
} |
- (void)dealloc |
{ |
[[NSNotificationCenter defaultCenter] removeObserver:self |
name:UIApplicationDidEnterBackgroundNotification |
object:nil]; |
[[NSNotificationCenter defaultCenter] removeObserver:self |
name:UIApplicationDidBecomeActiveNotification |
object:nil]; |
[[NSNotificationCenter defaultCenter] removeObserver:self |
name:[APLCloudManager UpdateContentWithNotification] |
object:nil]; |
} |
#pragma mark - UI Methods |
// update our navigation bar so that the edit and add button states are correct according to user login |
- (void)updateNavigationBar |
{ |
[CloudManager cloudServiceAvailable:^(BOOL available) { |
if ([CloudManager accountAvailable] && [CloudManager userLoginIsValid]) |
{ |
// we are logged in, iCloud drive is on |
// the edit button state should update |
self.editButtonItem.enabled = (self.photos.count > 0); |
// add button state should match if we have a container to read/write to |
self.addButton.enabled = YES; |
} |
else |
{ |
// we are not logged into iCloud |
// or |
// we are logged into iCloud, but iCloud drive is turned off |
// |
// we can just read but can't make any changes |
// note: in simulator for iOS 7 or earlier, for accountStatus you get: "CKAccountStatusNoAccount" |
// disable the edit and add button |
self.editButtonItem.enabled = self.addButton.enabled = NO; |
} |
}]; |
} |
// obtain the index row number of the given recordID, -1 if it cannot be found |
- (NSInteger)indexForPhotoWithRecordID:(CKRecordID *)recordID |
{ |
NSInteger foundIndex = -1; |
for (NSUInteger rowIdx = 0; rowIdx < self.photos.count; rowIdx++) |
{ |
CloudPhoto *photo = self.photos[rowIdx]; |
if ([photo.cloudRecord.recordID isEqual:recordID]) |
{ |
foundIndex = rowIdx; |
break; // we found the photo that needs updating, no need to continue searching |
} |
} |
return foundIndex; |
} |
// called as a result of a subscription notification: |
// update just the table cell this CKRecordID is associated with, |
// instead of just doing an entire table re-fetch, let's be efficient and just apply the update for the photo in question |
// |
- (void)updateTableWithRecordID:(CKRecordID *)recordID reason:(CKQueryNotificationReason)reason |
{ |
if (reason == CKQueryNotificationReasonRecordDeleted) |
{ |
// we are being asked to remove an existing photo |
NSInteger photoIndex = [self indexForPhotoWithRecordID:recordID]; |
if (photoIndex != -1) |
{ |
// we found a proper photo in our table view to be removed |
CloudPhoto *foundPhoto = self.photos[photoIndex]; |
// photo was removed, remove it from the table |
if (foundPhoto != nil) |
{ |
// we found the photo that needs removing |
[self.photos removeObject:foundPhoto]; |
// update our table |
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:photoIndex inSection:0]; |
[self.tableView beginUpdates]; |
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; |
[self.tableView endUpdates]; |
} |
} |
} |
else |
{ |
// we are being told a photo was added or updated |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
// first we need to fetch that photo |
[CloudManager fetchRecordWithID:recordID completionHandler:^(CKRecord *foundRecord, NSError *error) { |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
if (foundRecord != nil) |
{ |
// we have obtained the photo to be added or updated |
// |
NSInteger photoIndex = [self indexForPhotoWithRecordID:recordID]; |
if (reason == CKQueryNotificationReasonRecordUpdated) |
{ |
if (photoIndex >= 0) |
{ |
// we found the photo that needs "updating" |
// |
CloudPhoto *photoToReplace = [[CloudPhoto alloc] initWithRecord:foundRecord]; |
[self.photos replaceObjectAtIndex:photoIndex withObject:photoToReplace]; |
// update the cell with the new photo data |
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:photoIndex inSection:0]; |
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; |
((PhotoTableCell *)cell).photo = photoToReplace; |
// resort the list of photos, but keep track of its indexPath so we can move its table cell into the right place |
NSInteger oldIndexForPhoto = [self indexForPhotoWithRecordID:recordID]; |
NSInteger newIndexForPhoto = [self indexForPhotoWithRecordID:recordID]; |
NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:oldIndexForPhoto inSection:0]; |
NSIndexPath *newIndexPath = [NSIndexPath indexPathForRow:newIndexForPhoto inSection:0]; |
[self.tableView moveRowAtIndexPath:oldIndexPath toIndexPath:newIndexPath]; |
// update the photo cell |
[self.tableView reloadRowsAtIndexPaths:@[newIndexPath] withRowAnimation: UITableViewRowAnimationAutomatic]; |
NSSortDescriptor *sortDescriptor = |
[NSSortDescriptor sortDescriptorWithKey:[APLCloudManager PhotoTitleAttribute] ascending:YES]; |
[self.photos sortUsingDescriptors:@[sortDescriptor]]; |
} |
} |
else if (reason == CKQueryNotificationReasonRecordCreated) |
{ |
if (photoIndex == -1) // make sure the photo isn't already in the list |
{ |
// no photos were found on our list, so add this new one |
CloudPhoto *photoToAdd = [[CloudPhoto alloc] initWithRecord:foundRecord]; |
[self.photos addObject:photoToAdd]; |
// update our table |
NSInteger newIndexForPhoto = [self indexForPhotoWithRecordID:recordID]; |
[self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:newIndexForPhoto inSection:0]] withRowAnimation:UITableViewRowAnimationAutomatic]; |
// resort the list of photos |
NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:[APLCloudManager PhotoTitleAttribute] ascending:YES]; |
[self.photos sortUsingDescriptors:@[sortDescriptor]]; |
} |
} |
[self.tableView reloadData]; |
// update the edit button state (in case we had no photos before) |
self.editButtonItem.enabled = (self.photos.count > 0); |
} |
}]; |
} |
} |
#pragma mark - Photo Management |
- (void)loginUpdate |
{ |
// the user has signed in or out of iCloud, so we need to refresh our UI reflect user login |
// |
// re-load all the photos |
[self refresh:self]; // refresh/reload our table |
} |
// the primary search method for this app |
// |
- (void)loadPhotos:(void (^)(void))completionHandler |
{ |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
[CloudManager fetchRecords:^(NSArray *foundPhotos, NSError *error) { |
// done loading |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
self.tableView.dataSource = self; // now that we have data, we can start populating our table |
if (error != nil) |
{ |
if (error.code == CKErrorLimitExceeded) |
{ |
// the request to the server was too large. Retry this request as a smaller batch |
} |
else if (error.code == CKErrorServerRejectedRequest) |
{ |
// service or server problems (may be because the record type |
// is not defined in the schema yet or the schema was removed from CloudKit Dashboard) |
// |
} |
else if (error.code != CKErrorUnknownItem) |
{ |
// note we can get CKErrorUnknownItem for the first time the app is open |
// (no records added to that container yet, no schema defined) |
// |
} |
// On CKErrorServiceUnavailable or CKErrorRequestRateLimited errors: |
// the userInfo dictionary may contain a NSNumber instance that specifies the period of time in seconds after |
// which the client may retry the request. So here we will try again. |
// |
if (error.code == CKErrorServiceUnavailable || error.code == CKErrorRequestRateLimited) |
{ |
NSNumber *retryAfter = error.userInfo[CKErrorRetryAfterKey] ? : @3; // try again after 3 seconds if we don't have a retry hint |
NSLog(@"Error: %@. Recoverable, retry after %@ seconds", [error description], retryAfter); |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter.intValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
[self loadPhotos:completionHandler]; |
}); |
} |
else |
{ |
// due to an error, no photos should be shown |
_photos = nil; |
[self.tableView reloadData]; |
} |
} |
else |
{ |
// all is good, we get back an array of photos |
//NSLog(@"found %ld photos", (long)records.count); |
_photos = [NSMutableArray array]; |
// all is good, as we get back an array of CKRecords, convert them to CloudPhoto objects |
for (CKRecord *record in foundPhotos) |
{ |
[self.photos addObject:[[CloudPhoto alloc] initWithRecord:record]]; |
} |
[self.tableView reloadData]; |
if (self.restoringSearchState) |
{ |
// we are trying to restore state when our app was relaunched (UIStateRestoration) |
// so we must start our search filtering |
_restoringSearchState = NO; |
[self updateSearchResultsForSearchController:self.searchController]; |
} |
} |
// edit button should be disabled if there are no photos |
self.editButtonItem.enabled = (self.photos.count > 0); |
if (completionHandler != nil) |
{ |
completionHandler(); // invoke our caller's completion handler indicating we are done |
} |
}]; |
} |
// used by 'updateSearchResultsForSearchController' |
// called when user choose a different search scope in the search bar |
// |
- (void)finishPhotoFilteringByTitle:(NSString *)title photos:(NSArray *)photosToFilter |
{ |
NSArray *newlyFilteredPhotos = [photosToFilter copy]; |
// filter list further down by photo title |
if (title.length > 0) |
{ |
// if we have search text, filter down the results further |
NSPredicate *titleSearchPredicate = |
[NSPredicate predicateWithFormat:@"%K CONTAINS[cd] %@", [APLCloudManager PhotoTitleAttribute], title]; // [cd] = case insensitive |
newlyFilteredPhotos = [photosToFilter filteredArrayUsingPredicate:titleSearchPredicate]; |
} |
self.filteredPhotos = newlyFilteredPhotos; |
[self.tableView reloadData]; |
} |
- (void)searchAll |
{ |
// search for all photos |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
[self loadPhotos:^() { |
// done fetching, filter the photos further by photo title |
_searchedPhotos = self.photos; |
[self finishPhotoFilteringByTitle:self.searchController.searchBar.text photos:self.searchedPhotos]; |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
}]; |
} |
- (void)searchMine |
{ |
[CloudManager fetchLoggedInUserRecord:^(CKRecordID *loggedInUserRecordID) { |
// note we may get back a nil user record ID if the user is logged out of iCloud |
if (loggedInUserRecordID != nil) |
{ |
// we are logged in, so start the search |
CKRecordID *ourLoggedInRecordID = [[CKRecordID alloc] initWithRecordName:loggedInUserRecordID.recordName]; |
NSPredicate *searchPredicate = [NSPredicate predicateWithFormat:@"%K == %@", @"creatorUserRecordID", ourLoggedInRecordID]; |
[CloudManager fetchRecordsWithPredicate:searchPredicate completionHandler:^(NSArray *foundRecords, NSError *error) { |
// done fetching |
// |
// all is good, as we get back an array of CKRecords, convert them to CloudPhoto objects |
_searchedPhotos = [NSMutableArray array]; |
for (CKRecord *record in foundRecords) |
{ |
[self.searchedPhotos addObject:[[CloudPhoto alloc] initWithRecord:record]]; |
} |
// filter the photos further by photo title |
[self finishPhotoFilteringByTitle:self.searchController.searchBar.text photos:self.searchedPhotos]; |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
}]; |
} |
}]; |
} |
- (void)searchRecent |
{ |
// fetch for all recent records whose photo asset was created within the last number of "days" as input |
// |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
NSDate *now = [NSDate date]; |
NSTimeInterval secondsForDays = kRecentDays * 24 * 60 * 60; // recent is 5 days |
NSDate *lastDate = [NSDate dateWithTimeInterval:-secondsForDays sinceDate:now]; |
NSPredicate *startDatePredicate = [NSPredicate predicateWithFormat:@"%K >= %@", [APLCloudManager PhotoDateAttribute], lastDate]; |
NSPredicate *endDatePredicate = [NSPredicate predicateWithFormat:@"%K <= %@", [APLCloudManager PhotoDateAttribute], now]; |
NSCompoundPredicate *recentPredicate = [NSCompoundPredicate andPredicateWithSubpredicates:@[startDatePredicate, endDatePredicate]]; |
[CloudManager fetchRecordsWithPredicate:recentPredicate completionHandler:^(NSArray *foundRecords, NSError *error) { |
// done fetching |
// |
// all is good, as we get back an array of CKRecords, convert them to CloudPhoto objects |
_searchedPhotos = [NSMutableArray array]; |
for (CKRecord *record in foundRecords) |
{ |
[self.searchedPhotos addObject:[[CloudPhoto alloc] initWithRecord:record]]; |
} |
// filter the photos further photo title |
[self finishPhotoFilteringByTitle:self.searchController.searchBar.text photos:self.searchedPhotos]; |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
}]; |
} |
- (void)searchNearMe |
{ |
// fetch for all records within a "kNearMeDistance" kilometer radius of the user's location |
// |
// we might not have our user location yet |
if (self.currentLocation != nil) |
{ |
// for this scope to work, we "should" have the user's location by now |
// |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
NSPredicate *locationSearchPredicate = |
[NSPredicate predicateWithFormat:@"distanceToLocation:fromLocation:(PhotoLocation, %@) < %f", self.currentLocation, kNearMeDistance]; |
[CloudManager fetchRecordsWithPredicate:locationSearchPredicate completionHandler:^(NSArray *foundRecords, NSError *error) { |
// done fetching |
// |
// all is good, as we get back an array of CKRecords, convert them to CloudPhoto objects |
_searchedPhotos = [NSMutableArray array]; |
for (CKRecord *record in foundRecords) |
{ |
[self.searchedPhotos addObject:[[CloudPhoto alloc] initWithRecord:record]]; |
} |
// filter the photos further by kPhotoTitle attribute |
[self finishPhotoFilteringByTitle:self.searchController.searchBar.text photos:self.searchedPhotos]; |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
}]; |
} |
} |
#pragma mark - Actions |
- (void)setEditing:(BOOL)editing animated:(BOOL)animated |
{ |
[super setEditing:editing animated:animated]; |
if (!editing) |
{ |
// reset the navigation bar if we exit edit mode |
[self updateNavigationBar]; |
} |
else |
{ |
// disable the add button while in edit mode |
self.addButton.enabled = NO; |
} |
// hide/show toolbar on edit toggle |
[self.navigationController setToolbarHidden:!editing animated:YES]; |
} |
// called when UIRefreshControl is pulled down from our table |
- (void)refresh:(id)sender |
{ |
self.editButtonItem.enabled = NO; // no editing while refreshing |
[self loadPhotos:^() { |
// query completed, close out our refresh control |
[self.refreshControl endRefreshing]; |
[CloudManager fetchLoggedInUserRecord:^(CKRecordID *userRecordID) { |
[self updateNavigationBar]; |
}]; |
}]; |
} |
// called when the user decides to delete all photos |
- (void)deleteAllAction:(id)sender |
{ |
UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil |
message:NSLocalizedString(@"Confirm Remove", nil) |
preferredStyle:UIAlertControllerStyleActionSheet]; |
UIAlertAction *OKAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK Button Title", nil) |
style:UIAlertActionStyleDefault |
handler:^(UIAlertAction *action) { |
// OK button action |
[CloudManager fetchLoggedInUserRecord:^(CKRecordID *loggedInUserRecordID) { |
// find all of our photos that we own, delete only those |
// |
__block NSMutableArray *recordIDsToDelete = [NSMutableArray arrayWithCapacity:self.photos.count]; |
for (CloudPhoto *photo in self.photos) |
{ |
CKRecordID *userRecordID = photo.cloudRecord.creatorUserRecordID; |
if ([CloudManager isMyRecord:userRecordID]) |
{ |
// we found a deleted photo we own, add it to our removal list |
[recordIDsToDelete addObject:photo.cloudRecord.recordID]; |
} |
} |
// delete all operation means we exit edit mode |
[self setEditing:NO animated:YES]; |
if (recordIDsToDelete.count > 0) |
{ |
// remove our photos |
[CloudManager deleteRecordsWithIDs:recordIDsToDelete completionHandler:^(NSArray *deletedRecordIDs, NSError *error) { |
if (error != nil) |
{ |
NSLog(@"An error occured in '%@': error[%ld] %@", |
NSStringFromSelector(_cmd), (long)error.code, error.localizedDescription); |
} |
// photos are removed from the cloud, now proceed to remove our photos from our table and refresh |
for (CKRecordID *deletedRecordID in deletedRecordIDs) |
{ |
for (CloudPhoto *photo in self.photos) |
{ |
if ([photo.cloudRecord.recordID isEqual:deletedRecordID]) |
{ |
[self.photos removeObject:photo]; |
break; |
} |
} |
} |
[self.tableView reloadData]; |
// disable Edit button if we have no photos |
self.editButtonItem.enabled = (self.photos.count > 0); |
}]; |
} |
}]; |
}]; |
[alert addAction:OKAction]; |
UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel Button Title", nil) |
style:UIAlertActionStyleDefault |
handler:nil]; |
[alert addAction:cancelAction]; |
[self presentViewController:alert animated:YES completion:nil]; |
} |
#pragma mark - UISearchBarDelegate |
// called when UISearchBar's keyboard search button pressed |
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar |
{ |
// all we do here is dismiss the keyboard |
[searchBar resignFirstResponder]; |
} |
- (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)selectedScope |
{ |
// user tapped the scope bar, toggling between: All, Mine, Recents, or Near Me, to change the search criteria |
if (self.photos.count > 0) |
{ |
switch (self.searchController.searchBar.selectedScopeButtonIndex) |
{ |
case kAllScope: |
{ |
// search all photos |
[self searchAll]; |
break; |
} |
case kMineScope: |
{ |
// search for photos by owner (me) |
[self searchMine]; |
break; |
} |
case kRecentScope: |
{ |
// find photos created in the last 5 days |
[self searchRecent]; |
break; |
} |
case kNearMeScope: |
{ |
// we have tracked the user's location, and the user wants to search for photos for "near us" |
[self searchNearMe]; |
break; |
} |
} |
} |
} |
#pragma mark - UISearchControllerDelegate |
- (void)willPresentSearchController:(UISearchController *)searchController |
{ |
_searchedPhotos = [self.photos copy]; // start our search with all photos |
// configure the search bar scope buttons |
NSMutableArray *scopeTitles = [NSMutableArray arrayWithArray: |
@[NSLocalizedString(@"All Segment Item Title", nil), |
NSLocalizedString(@"Owner Segment Item Title", nil), |
NSLocalizedString(@"Recent Segment Item Title", nil)]]; |
// we might be called here early for state restoration, and we may not have a lock on the user's location |
// add the "Near Me" scope if we know the user's location, |
// (if not available, the scope will be added later in "didUpdateLocations") |
// |
if (self.currentLocation != nil) |
{ |
// we have the user's location, allow for search "Near Me" |
[scopeTitles addObject:NSLocalizedString(@"Near Me Segment Item Title", nil)]; |
} |
self.searchController.searchBar.scopeButtonTitles = scopeTitles; |
self.refreshControl.enabled = NO; // no refreshing the table while filtering |
} |
- (void)didDismissSearchController:(UISearchController *)searchController |
{ |
self.refreshControl.enabled = YES; // bring back refresh control |
[self.tableView reloadData]; // reset the table back since we are done searching |
} |
#pragma mark - UITableViewDelegate |
// utility report that the user can't delete a given photo, either because they are not logged in or an error was encountered |
// |
- (void)reportLogoutDeleteError:(NSError *)error |
{ |
NSString *messageStr = nil; |
if (error == nil || ([[error domain] isEqualToString:CKErrorDomain] && (error.code == CKErrorNotAuthenticated))) |
{ |
messageStr = NSLocalizedString(@"Removal alert detail message not logged in", nil); |
} |
else |
{ |
messageStr = [NSString stringWithFormat:@"Error domain/code: %@, %ld", [error domain], (long)error.code]; |
} |
UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Removal alert message not logged in", nil) |
message:messageStr |
preferredStyle:UIAlertControllerStyleAlert]; |
UIAlertAction *action = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK Button Title", nil) |
style:UIAlertActionStyleDefault |
handler:nil]; |
[alert addAction:action]; |
[self presentViewController:alert animated:YES completion:nil]; |
self.editing = NO; // bail out of edit mode due to error |
} |
- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath |
{ |
UITableViewCellEditingStyle editingStyle = UITableViewCellEditingStyleNone; |
// check if the given photo is our photo, allowing us to delete it |
CloudPhoto *photoToCheck = self.photos[indexPath.row]; |
CKRecordID *creatorRecordID = photoToCheck.cloudRecord.creatorUserRecordID; |
if ([CloudManager isMyRecord:creatorRecordID]) |
{ |
editingStyle = UITableViewCellEditingStyleDelete; |
} |
return editingStyle; |
} |
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath |
{ |
if (editingStyle == UITableViewCellEditingStyleDelete) |
{ |
CloudPhoto *photoToDelete = self.photos[indexPath.row]; |
CKRecordID *userRecordID = photoToDelete.cloudRecord.creatorUserRecordID; |
[CloudManager fetchLoggedInUserRecord:^(CKRecordID *foundUserRecordID) { |
if (foundUserRecordID == nil) |
{ |
// can't find logged in user record info, alert user we are logged out and delete is not possible |
[self reportLogoutDeleteError:nil]; |
} |
else if ([CloudManager isMyRecord:userRecordID]) |
{ |
// we own this photo, so we are allowed to delete it |
[CloudManager deleteRecordWithID:photoToDelete.cloudRecord.recordID completionHandler:^(CKRecordID *recordID, NSError *error) { |
if (error == nil) |
{ |
// change our table view (remove the deleted photo) |
[self.photos removeObject:photoToDelete]; |
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade]; |
if (self.photos.count == 0) |
{ |
// no more photos left, exit edit mode |
self.editing = NO; |
} |
// check if there are any photos left in the list that belong to the current logged in user |
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES; |
// we are logged in, so start the search of our photos |
CKRecordID *ourLoggedInRecordID = [[CKRecordID alloc] initWithRecordName:foundUserRecordID.recordName]; |
NSPredicate *searchPredicate = [NSPredicate predicateWithFormat:@"%K == %@", @"creatorUserRecordID", ourLoggedInRecordID]; |
[CloudManager fetchRecordsWithPredicate:searchPredicate completionHandler:^(NSArray *foundRecords, NSError *searchError) { |
// done fetching |
// |
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO; |
if (foundRecords.count == 0) |
{ |
// none of our photos were found, exit edit mode (no more deletions possible) |
[self setEditing:NO animated:YES]; |
} |
// edit button should be disabled if there are no photos or no records that belong to us |
self.editButtonItem.enabled = (self.photos.count > 0) && (foundRecords.count > 0); |
}]; |
} |
}]; |
} |
}]; |
} |
} |
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender |
{ |
APLDetailTableViewController *detailViewController = (APLDetailTableViewController *)segue.destinationViewController; |
// so we can be notified when a photo was changed by APLDetailTableViewController |
detailViewController.delegate = self; |
if ([segue.identifier isEqualToString:@"pushToDetail"]) |
{ |
if (self.searchController.isActive) |
{ |
// remember our search controller state, so next time we become visible there's no need to start a re-filter again |
_searchControllerActiveFromPreviousView = YES; |
} |
// pass the CloudPhoto to our detail view controller |
NSIndexPath *selectedIndexPath = [self.tableView indexPathForSelectedRow]; |
CloudPhoto *selectedPhoto = |
(self.searchController.active) ? |
self.filteredPhotos[selectedIndexPath.row] : self.photos[selectedIndexPath.row]; |
detailViewController.photo = selectedPhoto; |
} |
} |
#pragma mark - UITableViewDataSource |
// utility method returns YES if we have photos in our table, NO if no photos |
- (BOOL)shouldUseNoPhotosLabel |
{ |
BOOL shouldUseNoPhotosLabel = NO; |
NSInteger numberOfPhotos = |
(self.searchController.active) ? self.filteredPhotos.count : self.photos.count; |
if (numberOfPhotos == 0 && !self.searchController.active) |
{ |
// we need to show the "No Photos" label |
shouldUseNoPhotosLabel = YES; |
} |
return shouldUseNoPhotosLabel; |
} |
// show or hide the "No Photos" label centered in the table view if we have no photos to show |
- (void)hideOrShowNoRecordsLabel |
{ |
if ([self shouldUseNoPhotosLabel]) |
{ |
if (self.noPhotosLabel == nil) |
{ |
// add a "No Photos" label and place it centered within our table |
_noPhotosLabel = [[UILabel alloc] initWithFrame:CGRectNull]; |
self.noPhotosLabel.text = NSLocalizedString(@"No Photos", nil); |
self.noPhotosLabel.font = [UIFont systemFontOfSize:18]; |
[self.noPhotosLabel sizeToFit]; |
self.noPhotosLabel.textColor = [UIColor lightGrayColor]; |
} |
if (self.noPhotosLabel.superview == nil) |
{ |
// add and center the "No Photos" label |
[self.tableView addSubview:self.noPhotosLabel]; |
self.noPhotosLabel.translatesAutoresizingMaskIntoConstraints = NO; |
_labelConstraintForX = [NSLayoutConstraint constraintWithItem:self.noPhotosLabel |
attribute:NSLayoutAttributeCenterX |
relatedBy:NSLayoutRelationEqual |
toItem:self.tableView |
attribute:NSLayoutAttributeCenterX |
multiplier:1.0 |
constant:0.0]; |
_labelConstraintForY = [NSLayoutConstraint constraintWithItem:self.noPhotosLabel |
attribute:NSLayoutAttributeTop |
relatedBy:NSLayoutRelationEqual |
toItem:self.tableView |
attribute:NSLayoutAttributeTop |
multiplier:1.0 |
constant:100.0]; |
[self.tableView addConstraints:@[self.labelConstraintForX, self.labelConstraintForY]]; |
} |
} |
else |
{ |
if (self.noPhotosLabel.superview != nil) |
{ |
// remove the no photos label from the table |
[self.tableView removeConstraint:self.labelConstraintForX]; |
[self.tableView removeConstraint:self.labelConstraintForY]; |
[self.noPhotosLabel removeFromSuperview]; |
} |
} |
} |
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section |
{ |
[self hideOrShowNoRecordsLabel]; |
return (self.searchController.isActive) ? self.filteredPhotos.count : self.photos.count; |
} |
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath |
{ |
CloudPhoto *record = |
(self.searchController.active) ? self.filteredPhotos[indexPath.row] : self.photos[indexPath.row]; |
((PhotoTableCell *)cell).photo = record; |
} |
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath |
{ |
PhotoTableCell *cell = (PhotoTableCell *)[self.tableView dequeueReusableCellWithIdentifier:kCellIdentifier forIndexPath:indexPath]; |
return cell; |
} |
#pragma mark - UISearchResultsUpdating |
// Called when the search bar's text has changed |
// |
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController |
{ |
if (self.searchControllerActiveFromPreviousView) |
{ |
// search controller was active at the time we navigated to the detail view controller |
// no need to start a re-filter again |
// |
_searchControllerActiveFromPreviousView = NO; |
} |
else |
{ |
[self finishPhotoFilteringByTitle:self.searchController.searchBar.text photos:self.searchedPhotos]; |
} |
} |
#pragma mark - DetailViewControllerDelegate |
// we are being notified by APLDetailViewController, that a photo was added |
- (void)detailViewController:(APLDetailTableViewController *)viewController didAddCloudPhoto:(CloudPhoto *)photo |
{ |
// add the photo to the table |
[self updateTableWithRecordID:photo.cloudRecord.recordID reason:CKQueryNotificationReasonRecordCreated]; |
} |
// we are being notified by APLDetailViewController, that a photo was changed |
- (void)detailViewController:(APLDetailTableViewController *)viewController didChangeCloudPhoto:(CloudPhoto *)photo |
{ |
// update the photo in the table |
[self updateTableWithRecordID:photo.cloudRecord.recordID reason:CKQueryNotificationReasonRecordUpdated]; |
} |
// we are being notified by APLDetailViewController, that a photo was deleted |
- (void)detailViewController:(APLDetailTableViewController *)viewController didDeleteCloudPhoto:(CloudPhoto *)photo |
{ |
// delete the photo in the table |
[self updateTableWithRecordID:photo.cloudRecord.recordID reason:CKQueryNotificationReasonRecordDeleted]; |
} |
#pragma mark - Core Location |
// we received a location update |
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations |
{ |
_currentLocation = locations[0]; |
// now that we have the user's location, add the "Near Me" scope choice |
// to the search bar, if it doesn't already exist in the scope bar |
// |
NSMutableArray *scopeTitles = [self.searchController.searchBar.scopeButtonTitles mutableCopy]; |
if (scopeTitles.count == 3) |
{ |
[scopeTitles addObject:NSLocalizedString(@"Near Me Segment Item Title", nil)]; |
self.searchController.searchBar.scopeButtonTitles = scopeTitles; |
} |
if (self.wasResumed) // were we re-activated from the background? |
{ |
if (self.searchController.searchBar.selectedScopeButtonIndex == kNearMeScope) |
{ |
// if searching for "near me" as scope and in case the user moved his device since we were opened last, |
// update the table with the newer user location. |
// |
[self searchNearMe]; |
} |
_wasResumed = NO; |
} |
} |
// listen for authorization status changes on allowing to discover the user's location |
- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status |
{ |
if (status == kCLAuthorizationStatusDenied || status == kCLAuthorizationStatusRestricted) |
{ |
// user changed acccess to denied in System Preferences, remove tracking user location |
_currentLocation = nil; |
} |
else |
{ |
// we can now start scanning for the user's location |
// |
// The following obtains a quick fix on the user’s location. |
// Doing so automatically stops location services once the request has been fulfilled, |
// letting location hardware power down if not being used elsewhere. |
// Location updates requested in this manner are delivered by a callback to the locationManager:didUpdateLocations: delegate method |
// |
CLAuthorizationStatus authStatus = [CLLocationManager authorizationStatus]; |
if (authStatus == kCLAuthorizationStatusAuthorizedWhenInUse || |
authStatus == kCLAuthorizationStatusAuthorizedAlways) |
{ |
[self.locationManager requestLocation]; |
} |
} |
} |
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error |
{ |
self.currentLocation = nil; |
} |
#pragma mark - UIStateRestoration |
/* we restore several items for state restoration: |
1) Search controller's active state, |
2) search text, |
3) first responder status |
*/ |
static NSString *SearchControllerIsActiveKey = @"SearchControllerIsActiveKey"; |
static NSString *SearchBarTextKey = @"SearchBarTextKey"; |
static NSString *SearchBarIsFirstResponderKey = @"SearchBarIsFirstResponderKey"; |
static NSString *SearchBarScopeKey = @"SearchScopeBarScopeKey"; |
- (void)encodeRestorableStateWithCoder:(NSCoder *)coder |
{ |
[super encodeRestorableStateWithCoder:coder]; |
// encode the search controller's active state |
BOOL searchDisplayControllerIsActive = self.searchController.isActive; |
[coder encodeBool:searchDisplayControllerIsActive forKey:SearchControllerIsActiveKey]; |
// encode the first responser status |
if (searchDisplayControllerIsActive) |
{ |
[coder encodeBool:[self.searchController.searchBar isFirstResponder] forKey:SearchBarIsFirstResponderKey]; |
} |
// encode the search bar text |
[coder encodeObject:self.searchController.searchBar.text forKey:SearchBarTextKey]; |
// encode the search bar scope button index |
[coder encodeInteger:self.searchController.searchBar.selectedScopeButtonIndex forKey:SearchBarScopeKey]; |
} |
- (void)decodeRestorableStateWithCoder:(NSCoder *)coder |
{ |
[super decodeRestorableStateWithCoder:coder]; |
// restore the active state: |
// we can't make the searchController active here since it's not part of the view |
// hierarchy yet, instead we do it in viewWillAppear |
// |
_searchControllerWasActive = [coder decodeBoolForKey:SearchControllerIsActiveKey]; |
// restore the first responder status: |
// we can't make the searchController first responder here since it's not part of the view |
// hierarchy yet, instead we do it in viewWillAppear |
// |
_searchControllerSearchFieldWasFirstResponder = [coder decodeBoolForKey:SearchBarIsFirstResponderKey]; |
// restore the text in the search field |
_searchControllerText = [coder decodeObjectForKey:SearchBarTextKey]; |
// restore the scope button index in the search field |
_searchControllerScopeIndex = [coder decodeIntegerForKey:SearchBarScopeKey]; |
} |
@end |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-03-09