Working with NSURLSession: Part 3
In the previous tutorials, we explored the fundamentals of the
The project, however, will teach you how to use data tasks and download tasks in a real world application. The podcast client will also enable background downloads for which we'll leverage
With the table view still selected, open the Attributes Inspector, and set the number of prototype cells to
Drag a search bar from the Object Library and add it to the table view's header view. Select the search bar and connect its
Select the view controller and connect its
Open the Object Library and drag a bar button item to the navigation bar. Select the bar button item, connect it with the
Drag a bar button item to the navigation bar of the view controller (not the search view controller) and change its Identifier in the Attributes Inspector to Add. Control drag from the bar button item to the search view controller's navigation controller and select modal from the menu that pops up. This creates a segue from the view controller to the search view controller's navigation controller.
The implementation of
Before running the application, implement the
Build the project and run the application to make sure that the foundation is working as expected. It's time to start using the
The session is created in its getter method as you can see below. Its implementation shouldn't hold any surprises if you've read the previous tutorials. We override the getter method of the
Before we inspect
The heavy lifting is done in
Next, we ask the session for a new data task instance by passing it an
The logic inside the completion handler is interesting to say the least. The
If no error was passed to the completion handler, we create a dictionary from the
Before we look at the implementation of
In
The keyboard needs to hide the moment the user starts scrolling through the search results. To accomplish this, we implement
All we do in
This also means that we need to declare a property named
Let's also take a quick look at
When the value in the user defaults database changes for the key
When working with key value observing, it is instrumental to be aware of memory management and retain cycles. In this case, it means that we need to remove the view controller as an observer when the view controller is deallocated.
Open a Terminal window, navigate to the root of your Xcode project, and execute the command
Next, we update
In
We also need to implement two methods of the
In
Open the main storyboard one more time and add a table view to the view controller's view. Connect the table view's
Before we implement the
Implementing the
Run the application in the iOS Simulator or on a physical device and run it through its paces. You should now be able to search for podcasts, select a podcast from the list, and see its episodes.
NSURLSession
API. There is one other feature of the NSURLSession
API that we haven't look into yet, that is, out-of-process uploads and downloads. In the next two tutorials, I will show you how to create a very simple podcast client that enables background downloads.Introduction
The podcast client that we're about to create isn't really going to be that functional. It will allow the user to query the iTunes Search API for a list of podcasts, select a podcast, and download episodes. Since we are focusing on theNSURLSession
API, we won't go into playing the episodes the application downloads.The project, however, will teach you how to use data tasks and download tasks in a real world application. The podcast client will also enable background downloads for which we'll leverage
NSURLSession
's out-of-process API. We have quite a few things to do so let's not waste time and get started.1. Project Setup
Fire up Xcode 5, select New > Project... from the File menu, and choose the Single View Application template from the list of iOS application templates. Name the application Singlecast, set the Device Family to iPhone, and tell Xcode where you'd like to save the project. Hit Create to create the project.2. Update Storyboard
The first thing we need to do is edit the project's main storyboard. Open Main.storyboard, select the storyboard's only view controller, and choose Embed In > Navigation Controller from the Editor menu. The reason for embedding the view controller in a navigation controller will become clear later in this tutorial.3. Search View Controller
Step 1: Create Class Files
As I mentioned in the introduction, to keep things simple, the user will only be able to subscribe to one podcast. Let's start by creating the search view controller. Select New > File... from the File menu and choose Objective-C class from the options on the right. Name the classMTSearchViewController
and make it a subclass of UIViewController
. Leave the check box labeled With XIB for user interface unchecked. Tell Xcode where you want to save the class files and hit Create.Step 2: Update Class Interface
Before we create the user interface, open the view controller's header file and update the class's interface as shown below. We specify that theMTSearchViewController
class conforms to the UITableViewDataSource
, UITableViewDelegate
, and UISearchBarDelegate
protocols, we declare two outlets, searchBar
and tableView
as well as an action, cancel
, to dismiss the search view controller.01 02 03 04 05 06 07 08 09 10 | #import <UIKit/UIKit.h> @interface MTSearchViewController : UIViewController < UITableViewDataSource , UITableViewDelegate , UISearchBarDelegate> @property ( weak , nonatomic ) IBOutlet UISearchBar *searchBar; @property ( weak , nonatomic ) IBOutlet UITableView *tableView; - ( IBAction )cancel:( id )sender; @end |
Step 3: Create User Interface
Revisit the project's main storyboard and drag a new view controller from the Object Library on the right. Select the new view controller, open the Identity Inspector on the right, and set the view controller's class toMTSearchViewController
. With the new view controller still selected, open the Editor menu and choose Embed In > Navigation Controller. Drag a table view to the view controller's view and connect the table view's dataSource
and delegate
outlets with the search view controller.With the table view still selected, open the Attributes Inspector, and set the number of prototype cells to
1
. Select the prototype cell and set its style property to Subtitle and its identifier to SearchCell
.Drag a search bar from the Object Library and add it to the table view's header view. Select the search bar and connect its
delegate
outlet with the view controller.Select the view controller and connect its
searchBar
and tableView
outlets with the search bar and table view respectively. There are a few other things that we need to do before we're done with the storyboard.Open the Object Library and drag a bar button item to the navigation bar. Select the bar button item, connect it with the
cancel:
action we declared in the search view controller's interface, and change its Identifier in the Attributes Inspector to Cancel.Drag a bar button item to the navigation bar of the view controller (not the search view controller) and change its Identifier in the Attributes Inspector to Add. Control drag from the bar button item to the search view controller's navigation controller and select modal from the menu that pops up. This creates a segue from the view controller to the search view controller's navigation controller.
If you were to control drag from the view controller's bar button item directly to the search view controller instead of its navigation controller, the navigation controller would never be instantiated and you wouldn't see a navigation bar at the top of the search view controller.
Step 4: Table View Implementation
Before we implement theUITableViewDataSource
and UITableViewDelegate
protocols in the MTSearchViewController
class, we need to declare a property that stores the search results we'll get back from the iTunes Search API. Name the property podcasts
as shown below. We also declare a static string that will serve as a cell reuse identifier. It corresponds to the identifier we set on the prototype cell a few moments ago.1 2 3 4 5 6 7 | #import "MTSearchViewController.h" @interface MTSearchViewController () @property (strong, nonatomic ) NSMutableArray *podcasts; @end |
1 | static NSString * SearchCell = @"SearchCell" ; |
numberOfSectionsInTableView:
is as easy as it gets. We return 1
if self.podcasts
is not nil
and 0
if it is. The implementation of tableView:numberOfRowsInSection:
is pretty similar as you can see below. In tableView:cellForRowAtIndexPath:
, we ask the table view for a cell by passing the cell reuse identifier, which we declared earlier, and indexPath
. We fetch the corresponding item from the podcasts
data source and update the table view cell. Both tableView:canEditRowAtIndexPath:
and tableView:canMoveRowAtIndexPath:
return NO
.1 2 3 | - (NSInteger)numberOfSectionsInTableView:( UITableView *)tableView { return self .podcasts ? 1 : 0 ; } |
1 2 3 | - (NSInteger)tableView:( UITableView *)tableView numberOfRowsInSection :(NSInteger)section { return self .podcasts ? self .podcasts .count : 0 ; } |
01 02 03 04 05 06 07 08 09 10 11 12 | - ( UITableViewCell *)tableView:( UITableView *)tableView cellForRowAtIndexPath :( NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier : SearchCell forIndexPath :indexPath]; // Fetch Podcast NSDictionary *podcast = [ self .podcasts objectAtIndex :indexPath .row ]; // Configure Table View Cell [cell .textLabel setText :[podcast objectForKey : @"collectionName" ]]; [cell .detailTextLabel setText :[podcast objectForKey : @"artistName" ]]; return cell; } |
1 2 3 | - ( BOOL )tableView:( UITableView *)tableView canEditRowAtIndexPath :( NSIndexPath *)indexPath { return NO ; } |
1 2 3 | - ( BOOL )tableView:( UITableView *)tableView canMoveRowAtIndexPath :( NSIndexPath *)indexPath { return NO ; } |
cancel:
action in which we dismiss the search view controller.1 2 3 | - ( IBAction )cancel:( id )sender { [ self dismissViewControllerAnimated : YES completion :nil ]; } |
NSURLSession
API to query the iTunes Search API.Step 5: Creating a Session
Let's begin by declaring two additional private properties in theMTSearchViewController
class, session
and dataTask
. The session
variable is used to store a reference to the NSURLSession
instance we'll be using for querying Apple's API. We also keep a reference to the data task that we will use for the request. This will enable us to cancel the data task if the user updates the search query before we've received a response from the API. If you have an eye for detail, you may have noticed that the MTSearchViewController
class also conforms to the UIScrollViewDelegate
protocol. The reason for this will become clear in a few minutes.01 02 03 04 05 06 07 08 09 10 | #import "MTSearchViewController.h" @interface MTSearchViewController () <UIScrollViewDelegate> @property (strong, nonatomic ) NSURLSession *session; @property (strong, nonatomic ) NSURLSessionDataTask *dataTask; @property (strong, nonatomic ) NSMutableArray *podcasts; @end |
session
property to lazily load the session and confine the session's instantiation and configuration in its getter method. This makes for clean and elegant code.01 02 03 04 05 06 07 08 09 10 11 12 13 14 | - ( NSURLSession *)session { if (!_session) { // Initialize Session Configuration NSURLSessionConfiguration *sessionConfiguration = [ NSURLSessionConfiguration defaultSessionConfiguration ]; // Configure Session Configuration [sessionConfiguration setHTTPAdditionalHeaders :@{ @"Accept" : @"application/json" }]; // Initialize Session _session = [ NSURLSession sessionWithConfiguration :sessionConfiguration]; } return _session; } |
Step 6: Searching
To respond to the user's input in the search bar, we implementsearchBar:textDidChange:
of the UISearchBarDelegate
protocol. The implementation is simple. If searchText
is nil
, the method returns early. If the length of searchText
is less than four characters long, we reset the search by invoking resetSearch
. If the query is four characters or longer, we perform a search by calling performSearch
on the search view controller.01 02 03 04 05 06 07 08 09 10 | - ( void )searchBar:( UISearchBar *)searchBar textDidChange :( NSString *)searchText { if (!searchText) return ; if (searchText .length <= 3 ) { [ self resetSearch ]; } else { [ self performSearch ]; } } |
performSearch
, let's take a quick look at resetSearch
. All that we do in resetSearch
is clearing the contents of podcasts
and reloading the table view.1 2 3 4 5 6 7 | - ( void )resetSearch { // Update Data Source [ self .podcasts removeAllObjects ]; // Update Table View [ self .tableView reloadData ]; } |
performSearch
. After storing the user's input in a variable named query
, we check if dataTask
is set. If it is set, we call cancel
on it. This is important as we don't want to receive a response from an old request that may no longer be relevant to the user. This is also the reason why we have only one active data task at any one time. There is no advantage in sending multiple requests to the API.Next, we ask the session for a new data task instance by passing it an
NSURL
instance and a completion handler. Remember that the session is the factory that creates tasks. You should never create tasks yourself. If we get a valid data task from the session, we call resume
on it as we saw in the previous tutorials.The logic inside the completion handler is interesting to say the least. The
error
object is important to us for several reasons. Not only will it tell us if something went wrong with the request, but it's also useful for determining if the data task was canceled. If we do get an error object, we check whether its error code is equal to -999
. This error code indicates the data task was canceled. If we get another error code, we log the error to the console. In a real application, you'd need to improve the error handling and notify the user when an error is thrown.If no error was passed to the completion handler, we create a dictionary from the
NSData
instance that was passed to the completion handler and we extract the results from it. If we have an array of results to work with, we pass it to processResults:
. Did you notice we invoked processResults:
in a GCD (Grand Central Dispatch) block? Why did we do that? I hope you remember, because it's a very important detail. We have no guarantee that the completion handler is invoked on the main thread. Since we need to update the table view on the main thread, we need to make sure that processResults:
is called on the main thread.01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | - ( void )performSearch { NSString *query = self .searchBar .text ; if ( self .dataTask ) { [ self .dataTask cancel ]; } self .dataTask = [ self .session dataTaskWithURL :[ self urlForQuery :query] completionHandler :^( NSData *data, NSURLResponse *response, NSError *error) { if (error) { if (error .code != - 9 9 9 ) { NSLog( @"%@" , error); } } else { NSDictionary *result = [ NSJSONSerialization JSONObjectWithData :data options : 0 error :nil ]; NSArray *results = [result objectForKey : @"results" ]; dispatch_async(dispatch_get_main_queue(), ^{ if (results) { [ self processResults :results]; } }); } }]; if ( self .dataTask ) { [ self .dataTask resume ]; } } |
processResults:
, I want to quickly show you what happens in urlForQuery:
, the helper method we use in performSearch
. In urlForQuery:
, we replace any spaces with a +
sign to ensure that the iTunes Search API is happy with what we send it. We then create an NSURL
instance with it and return it.1 2 3 4 | - ( NSURL *)urlForQuery:( NSString *)query { query = [query stringByReplacingOccurrencesOfString : @" " withString : @"+" ]; return [ NSURL URLWithString :[ NSString stringWithFormat : @"https://itunes.apple.com/search?media=podcast&entity=podcast&term=%@" , query ]]; } |
processResults:
, the podcasts
variable is cleared, populated with the contents of results
, and the results are displayed in the table view.01 02 03 04 05 06 07 08 09 10 11 12 | - ( void )processResults:( NSArray *)results { if (! self .podcasts ) { self .podcasts = [ NSMutableArray array ]; } // Update Data Source [ self .podcasts removeAllObjects ]; [ self .podcasts addObjectsFromArray :results]; // Update Table View [ self .tableView reloadData ]; } |
Step 6: Selecting a Podcast
When the user taps a row in the table view to select a podcast,tableView:didSelectRowAtIndexPath:
of the UITableViewDelegate
protocol is invoked. Its implementation may seem odd at first so let me explain what's going on. We select the podcast that corresponds with the user's selection, store it in the application's user defaults database, and dismiss the search view controller. We don't notify anyone about this? Why we do this will become clear once we continue implementing the MTViewController
class.01 02 03 04 05 06 07 08 09 10 11 12 13 14 | - ( void )tableView:( UITableView *)tableView didSelectRowAtIndexPath :( NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath :indexPath animated : YES ]; // Fetch Podcast NSDictionary *podcast = [ self .podcasts objectAtIndex :indexPath .row ]; // Update User Defatuls NSUserDefaults *ud = [ NSUserDefaults standardUserDefaults ]; [ud setObject :podcast forKey : @"MTPodcast" ]; [ud synchronize ]; // Dismiss View Controller [ self dismissViewControllerAnimated : YES completion :nil ]; } |
Step 7: Finishing Touches
There are two details I want to talk about before returning to theMTViewController
class. When the search view controller is presented to the user, it is clear that she wants to search for podcasts. It is therefore a good idea to immediately present the keyboard. We do this in viewDidAppear:
as shown below.1 2 3 4 5 6 | - ( void )viewDidAppear:( BOOL )animated { [ super viewDidAppear :animated]; // Show Keyboard [ self .searchBar becomeFirstResponder ]; } |
scrollViewDidScroll:
of the UIScrollViewDelegate
protocol. This explains why MTSearchViewController
conforms to the UIScrollViewDelegate
protocol. Have a look at the implementation of scrollViewDidScroll:
shown below.1 2 3 4 5 | - ( void )scrollViewDidScroll:( UIScrollView *)scrollView { if ([ self .searchBar isFirstResponder ]) { [ self .searchBar resignFirstResponder ]; } } |
The
UITableView
class is a subclass of UIScrollView
, which is the reason the above approach works. 4. Looping Back
As we saw earlier, we store the user's selection in the application's user defaults database. We need to update theMTViewController
class to make use of the user's selection in the search view controller. In the view controller's viewDidLoad
method, we load the podcast from the user defaults database and we add the view controller as an observer of the user defaults database for the key path MTPodcast
so that the view controller is notified when the value for MTPodcast
changes.1 2 3 4 5 6 7 8 9 | - ( void )viewDidLoad { [ super viewDidLoad ]; // Load Podcast [ self loadPodcast ]; // Add Observer [[ NSUserDefaults standardUserDefaults ] addObserver : self forKeyPath : @"MTPodcast" options : NSKeyValueObservingOptionNew context : NULL ]; } |
loadPodcast
is storing the value for MTPodcast
from the user defaults database in the view controller's podcast
property. This value will be nil
if the user defaults database doesn't contain an entry for MTPodcast
. The view controller will gracefully handle this for us. Remember that, in Objective-C, you can send messages to nil
without all hell breaking loose. This has its disadvantages, but it certainly has its advantages to.1 2 3 4 | - ( void )loadPodcast { NSUserDefaults *ud = [ NSUserDefaults standardUserDefaults ]; self .podcast = [ud objectForKey : @"MTPodcast" ]; } |
podcast
in the view controller's implementation file.1 2 3 4 5 6 7 | #import "MTViewController.h" @interface MTViewController () @property (strong, nonatomic ) NSDictionary *podcast; @end |
setPodcast:
and updateView
.1 2 3 4 5 6 7 8 | - ( void )setPodcast:( NSDictionary *)podcast { if (_podcast != podcast) { _podcast = podcast; // Update View [ self updateView ]; } } |
1 2 3 4 | - ( void )updateView { // Update View self .title = [ self .podcast objectForKey : @"collectionName" ]; } |
MTPodcast
, the view controller can respond to this change in observeValueForKeyPath:ofObject:change:context:
. That's how key value observing works. All we do in this method is updating the value of the view controller's podcast
property.1 2 3 4 5 | - ( void )observeValueForKeyPath:( NSString *)keyPath ofObject :( id )object change :( NSDictionary *)change context :( void *)context { if ([keyPath isEqualToString : @"MTPodcast" ]) { self .podcast = [object objectForKey : @"MTPodcast" ]; } } |
1 2 3 | - ( void )dealloc { [[ NSUserDefaults standardUserDefaults ] removeObserver : self forKeyPath : @"MTPodcast" ]; } |
5. Fetching and Parsing the Feed
Step 1: Adding Dependencies
The response we get back from the iTunes Search API includes afeedUrl
attribute for each podcast. We could manually fetch the feed and parse it. However, to save some time, we'll make use of MWFeedParser, a popular library that can do this for us. You can manually download and include the library in your project, but I am going to opt for Cocoapods. I prefer Cocoapods for managing dependencies in iOS and OS X projects. You can read more about Cocoapods on its website or on Mobiletuts+.I am going to assume the Cocoapods gem is installed on your system. You can find detailed instructions in this tutorial.
Quit Xcode, navigate to the root of your Xcode project, and create a file named Podfile. Open this file in your text editor of choice and add the following three lines of code. In the first line, we specify the platform and the deployment target, which is iOS 7 in this example. The next two lines each specify a dependency of our Xcode project. The first one is the MWFeedParser library and I've also included the popular SVProgressHUD library, which will come in handy a bit later.1 2 3 4 | platform :ios , '7' pod 'MWFeedParser' pod 'SVProgressHUD' |
pod install
. This should install the dependencies and create an Xcode workspace. When Cocoapods is finished installing the project's dependencies, it tells you to use the workspace it created for you. This is important so don't ignore this advice. In the root of your Xcode project, you will see that Cocoapods has indeed created an Xcode workspace for you. Double-click this file and you should be ready to go.Step 2: Fetching and Parsing the Feed
Open the implementation file of theMTViewController
class, add an import statement for MWFeedParser and SVProgressHUD, and declare two properties, episodes
and feedParser
. We also need to make MTViewController
conform to the MWFeedParserDelegate
protocol.01 02 03 04 05 06 07 08 09 10 11 12 | #import "MTViewController.h" #import "MWFeedParser.h" #import "SVProgressHUD.h" @interface MTViewController () <MWFeedParserDelegate> @property (strong, nonatomic ) NSDictionary *podcast; @property (strong, nonatomic ) NSMutableArray *episodes; @property (strong, nonatomic ) MWFeedParser *feedParser; @end |
setPodcast:
by invoking fetchAndParseFeed
, a helper method in which we use the MWFeedParser
class to fetch and parse the podcast's feed.01 02 03 04 05 06 07 08 09 10 11 | - ( void )setPodcast:( NSDictionary *)podcast { if (_podcast != podcast) { _podcast = podcast; // Update View [ self updateView ]; // Fetch and Parse Feed [ self fetchAndParseFeed ]; } } |
fetchAndParseFeed
, we get rid of our current MWFeedParser
instance if we have one and initialize a new instance with the podcast's feed URL. We set the feedParseType
property to ParseTypeFull
and set the view controller as the feed parser's delegate. Before we fetch the feed, we use SVProgressHUD
to show a progress HUD to the user.01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | - ( void )fetchAndParseFeed { if (! self .podcast ) return ; NSURL *url = [ NSURL URLWithString :[ self .podcast objectForKey : @"feedUrl" ]]; if (!url) return ; if ( self .feedParser ) { [ self .feedParser stopParsing ]; [ self .feedParser setDelegate :nil ]; [ self setFeedParser :nil ]; } // Clear Episodes if ( self .episodes ) { [ self setEpisodes :nil ]; } // Initialize Feed Parser self .feedParser = [[ MWFeedParser alloc ] initWithFeedURL :url]; // Configure Feed Parser [ self .feedParser setFeedParseType :ParseTypeFull]; [ self .feedParser setDelegate : self ]; // Show Progress HUD [ SVProgressHUD showWithMaskType :SVProgressHUDMaskTypeGradient]; // Start Parsing [ self .feedParser parse ]; } |
MWFeedParserDelegate
protocol, feedParser:didParseFeedItem:
and feedParserDidFinish:
. In feedParser:didParseFeedItem:
, we initialize the episodes
property if necessary and pass it the feed item that the feed parser hands to us.1 2 3 4 5 6 7 | - ( void )feedParser:( MWFeedParser *)parser didParseFeedItem :( MWFeedItem *)item { if (! self .episodes ) { self .episodes = [ NSMutableArray array ]; } [ self .episodes addObject :item]; } |
feedParserDidFinish:
, we dismiss the progress HUD and update the table view. Did you say table view? That's right. We need to add a table view and implement the necessary UITableViewDataSource
protocol methods.1 2 3 4 5 6 7 | - ( void )feedParserDidFinish:( MWFeedParser *)parser { // Dismiss Progress HUD [ SVProgressHUD dismiss ]; // Update View [ self .tableView reloadData ]; } |
Step 3: Displaying the Feed
Before we update the user interface, openMTViewController.h
, declare an outlet for the table view, and tell the compiler the MTViewController
class conforms to the UITableViewDataSource
and UITableViewDelegate
protocols.1 2 3 4 5 6 7 | #import <UIKit/UIKit.h> @interface MTViewController : UIViewController < UITableViewDataSource , UITableViewDelegate> @property ( weak , nonatomic ) IBOutlet UITableView *tableView; @end |
dataSource
and delegate
outlets with the view controller and connect the view controller's tableView
outlet with the table view. Select the table view, open the Attributes Inspector, and set the number of prototype cells to 1
. Select the prototype cell, set its style to Subtitle, and give it an identifier of EpisodeCell.Before we implement the
UITableViewDataSource
protocol, declare a static string named EpisodeCell
in MTViewController.m. This corresponds with the identifier we set for the prototype cell in the storyboard.1 | static NSString * EpisodeCell = @"EpisodeCell" ; |
UITableViewDataSource
protocol is simple as pie and very similar to how we implemented the protocol in the search view controller. The only difference is that the episodes
variable contains instances of the MWFeedItem
class instead of NSDictionary
instances.1 2 3 | - (NSInteger)numberOfSectionsInTableView:( UITableView *)tableView { return self .episodes ? 1 : 0 ; } |
1 2 3 | - (NSInteger)tableView:( UITableView *)tableView numberOfRowsInSection :(NSInteger)section { return self .episodes ? self .episodes .count : 0 ; } |
01 02 03 04 05 06 07 08 09 10 11 12 | - ( UITableViewCell *)tableView:( UITableView *)tableView cellForRowAtIndexPath :( NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier : EpisodeCell forIndexPath :indexPath]; // Fetch Feed Item MWFeedItem *feedItem = [ self .episodes objectAtIndex :indexPath .row ]; // Configure Table View Cell [cell .textLabel setText :feedItem .title ]; [cell .detailTextLabel setText :[ NSString stringWithFormat : @"%@" , feedItem .date ]]; return cell; } |
1 2 3 | - ( BOOL )tableView:( UITableView *)tableView canEditRowAtIndexPath :( NSIndexPath *)indexPath { return NO ; } |
1 2 3 | - ( BOOL )tableView:( UITableView *)tableView canMoveRowAtIndexPath :( NSIndexPath *)indexPath { return NO ; } |
Comments
Post a Comment