I have a UITableView with cells that display an audio waveform image and a playback button. Tapping the button causes the audio to be played back, of course. In order to reduce memory usage, I have a single instance of AVAudioPlayer declared in my table view controller. I defined a protocol that has playAudio(url:URL) and stopPlayingAudio() methods and my table view controller conforms to this protocol. Anytime a new cell is dequeued, I assign the table view controller as the delegate for the cell so that when the user taps on the playback button in the cell UI, it calls the playAudio(url) delegate method. This seems to be working well enough but I've run into a problem now.
I'm calculating a percent complete value as the audio is playing and I'd like to update the table view cell UI with this value but I'm not sure how to reference the correct cell from the table view controller. It seems like the cell that was tapped on to start the audio playback could end up getting recycled if it scrolls off the screen (unless I'm misunderstanding how cells get dynamically dequeued). Is there a way to do this?
If you know what row of the table you're looking for, you can ask the table view for the corresponding cell:
guard let cell = tableView.cellForRow(at: indexPath) as? WaveformCell {
cell.fractionComplete = ...
}
There are a few ways in which you can achieve what you intend to achieve,
If you are maintaining a datasource to create a cell from(which you should if you are not), maintain the state of the cell, this can include the percentage played of the cell's url and the state whether the item isPlaying, which will be false by default.
Once the states are in place, you need to now update this state, so you will have to add create a protocol(say AudioStateObserverProtocol) for sending this data to the cell, this protocol may have a method which periodically updates the cell UI as the player plays (something like, updatePlayDuration: or something of this sort), this will make sure that you get the value of how much of the asset has played. So when the user taps the play button instead of calling playAudio(url:URL) you can update the protocol method to playAudio(url:URL, stateObserver: TheTableViewCell), which the table view controller will set to as the delegate of type AudioStateObserverProtocol.
protocol AudioStateObserverProtocol {
func updatePlayDuration(to time: CMTime)
}
Add another protocol method stopObserving(cell: TheTableViewCell) to the protocol you have defined with playAudio(url:URL) and stopPlayingAudio()
The next step is how to make sure that the cell on reuse does not still receive/use the update, to do this you can make sure that when you setup the cell in your cellForRow datasource methods you first call the stopObserving(cell: TheTableViewCell). In your implementation of this method inside the tableview controller check for the instance of the cell against the param of type AudioStateObserverProtocol and if same, set it to nil so that this cell does not get the updates again.
One important thing to keep in mind here is that, if your audio is still playing then you need to make sure that when the cell for that index is getting created it show updates, this is when you will check the isPlaying state of the datasource and if it is true set the cell as the observer of type AudioStateObserverProtocol
Related
I've found some similar questions already on SO, but nothing that seems to address this specific problem.
I'm using a UITableView with around 25 dynamic cells. Each cells contains a hidden UIProgressView. When the user taps on the download button within the cell to download that item, the UIProgressView is displayed and it indicates the progress of the download.
I've achieved this by assigning a tag to each cell which is equivalent to its corresponding downloadItemID. Each instance of MyCell has its own progressBar property.
This works fine as long as the table view is not scrolled during the download. It works also when the user is downloading multiple items at the same time. The code looks like this:
UIProgressView *theProgressBar;
for (MyCell *cell in self.tableView.visibleCells)
{
if (cell.tag == downloadItemID) theProgressBar = cell.progressBar;
}
float progressPercentage = (float)completedResources / expectedResources;
[theProgressBar setProgress:progressPercentage animated:YES];
The problem is that when the user scrolls the table view, the cell and progress view are transferred to another cell. It's simple enough to reset and hide the progress view for the new cell, but when the original/downloading cell is scrolled back into view, no progress is visible.
I've tried caching active progress bars into a dictionary and reallocating them back to the original cell in cellForRowAtIndexPath, but this is giving me the same result: no visible progress after scrolling the cell off and on the screen. Even if I can get them to show up, I'm doubtful I can get this to work seamlessly by rolling my own caching method.
What I need is to keep cells in memory. But can I work around dequeueReusableCellWithIdentifier? This whole problem has arisen because I had to switch to a dynamic system of allocating the cells, but it is too dynamic. Can I create the cells dynamically, but keep them in memory all the time once created, or at least keep the ones that are currently downloading?
(I understand the reasons why cell reuse is the preferred way to go with table views.)
You are working against the framework. As vadian says in the comment, you need to separate the logic and state information from the cells and put them elsewhere.
Here is one way of doing it.
Create a class to hold each download state, like a download ongoing flag, download progress, title, URL, etc.
In your view controller, create and add all your download objects to an array when the view controller is created. The first object corresponds to the first row in the table.
Whenever you dequeue a cell, populate it with data from the array. The NSIndexPath row property serves as the index into the array.
All your updates (download progress) updates the download objects in the array, then update the cell content using the updated object. You can use UITableView cellForRowAtIndexPath to get the cell for a specific array index, if you get nil there is no need to update it.
Here's a dynamic cell
Note - in the example, the text is not data driven. It's just some text local to the cell (consider, say, a help text). At runtime, change the .text of the UILabel from one word to many lines, using a button actually inside the cell. iOS perectly resizes the cell and table....
... but only when the cell is scrolled offscreen, and then on again.
How to alert the table view to recalculate everything "now" ?
(Please note, this question ONLY in the case of iOS8+, Xcode7+, autolayout for dynamic cell heights.)
Changing height
So basically, there are two ways to do:
The first one is to actually reload the cell (not the tableview). Reloading will call new heightForRow (don't forget to purge cache, if you are caching the sizes), which will return proper new height:
let indexPaths = [NSIndexPath(forRow: ~the rows in question~, inSection: 0)]
self.table.reloadRowsAtIndexPaths(indexPaths, withRowAnimation: .Automatic)
(Note however that this often involves reloading more than one row; notably if you select/deselect, you have to reload all rows changed.)
If however you ONLY want to change the size of the cell and the content per se, and did not really change the data content ... so for example:
you clicked some button and you assigned new local text in the cell to outlets (perhaps a help text):
you changed only the LAYOUT of the cell. for example, you made a font larger, or changed the margin of a block of text so that the height of a block of text changed, so indeed the height of the overall cell changed:
In that case instead of reloading, just call the following, which forces the tableview to basically do all animations, and for that it needs new heights, so it requests it:
self.table.beginUpdates()
self.table.endUpdates()
The true solution
I see what your problem is. You are trying to change the height of the cell from the actual cell - but you will not succeed in that -> and you should not. See, the cell is view, and view should not have any idea about its data whatsoever - view is what presents. If you need any changes, you should inform your controller to do so. To do that, you can use notifications, but preferably protocols / delegates.
So at first you create protocol in your cell, which will be used to inform the controller, that there is a change:
protocol MyCellDelegate {
func buttonTappedForCell(cell : UITableViewCell)
}
Now, you need to conform to that protocol in your view controller that contains table:
class MyClassWithTableView : MyCellDelegate
Lastly, you need to declare delegate in the cell:
class MyCell {
var delegate : MyCellDelegate
}
And assign it in the configuration of the cell, which you probably have in the view controller:
cell.delegate = self
This is the basic setup for all the delegates / protocols really, and now, when you click on your button, you can forward the action to your controller:
#IBAction myButtonTouchUpInside() {
self.delegate.buttonTappedForCell(self)
}
After doing all that, proceed as in part 1. That is to say, either reloadRowsAtIndexPaths or a beginUpdates / endUpdates pair as explained above.
Hope it helps!
I'm presuming you're not setting the text property of the UILabel inside cellForRowAtIndexPath but rather somewhere else (or doing it asynchronously). If that's the case, I wouldn't update the UI there. Rather, I'd update the model backing the table and then call reloadRowsAtIndexPaths. That will let cellForRowAtIndexPath call again, but unlike reloading the whole table, this will gracefully keep the contentOffset of the tableview right where it is.
I know this all sounds unnecessarily complicated, but the bottom line is that you don't own this view, the table view does. It has to do all sorts of stuff above and beyond updating the cell. I.e., if the cell grew, figure out which cells scrolled out of view and dequeue them. If the cell shrunk, figure out which cells scrolled into view as a result.
It's a surprisingly complex dance. You can try calling setNeedsLayout on the cell, but I wouldn't expect that to work (and even if it does, it is a fragile approach). The table view is responsible for managing its cells, so if you really should just update model and reload that one cell.
did you try calling reloadRowsAtIndexPaths on the cell index? it's supposed to animate to the new size, if the constraints are setup correctly.
You should call self.tableView.reloadData() just AFTER you made the cell's label's text change.
It will force the tableView to redraw the cell's. That's what happened when you scroll, the cell is being reused, and redrawn when it comes back again.
EDIT:
If you can't or won't do a reloadData on your tableView, you can use:
self.tableView.beginUpdates()
self.tableView.reloadRowsAtIndexPaths([NSIndexPath(row:0 section:0)] withRowAnimation:UITableViewRowAnimation.Automatic)
self.tableView.endUpdates()
I dont know your code but did you really execute your ui changes on the main thread. Same problem happened to me and was solved with putting the exectuion on the main thread.
dispatch_async(dispatch_get_main_queue()) { () -> Void in
[...]
}
I have a problem with getting data from cells in UITableView.
I have a lots of cells (other class with xib) with some people info in table view. Every cell has own switch.
Under table is button that will send message to every person with switch pressed on.
I set property in people class that will remember if switch for person is on. Every switch in cellForRow method has set selector with method for switch action.
Problem is that I have no idea, where to set person property value about switch state. In selector method I don't have access to person info. I have sender, but no info about person.
I'd do this in one of 2 ways depending on what exactly you're trying to do:
Pass a pointer of the person object to the cell when you're configuring it and let the cell handle updating the property that holds the value of the switch.
Make a protocol with a function
-(void)switchInCell:(UITableViewCell*) cell valueModifiedTo:(NSInteget) newValue
Adopt the new protocol in your view controller, add a delegate property of type new protocol to the cell, call that delegate method from within the cell if the switch value changes.
In that method implementation in your view controller, you can do
NSIndexPath* indexPath = [theTable indexPathForCell:cell];
PersonClass* person = Your_DataSource_Array [indexPath.row];
//modify person properties
You can set the UIView tag property to a number, for each of the subviews of the table View Cell's "content" property, at the cell's creation time (e.g. when you add all the UIViews, e.g. labels, buttons, etc...), giving each subview a unique value.
Later, if just given the UITableViewCell, you can iterate through the subviews of is content view and locate your switch (and any other UIViews) by checking their tag value.
If you are using custom cells, you can have method for your switch inside the cell's implementation. From the method, you can get back to the data source using delegation, or notifications and you can pass indexPath of the cell for further identification of the object in the data. You can have a property to store the indexPath of the cell in implementation of the cell's class and you can set the value to that property in cellForItemAtIndexPath: method. Good Luck!
you said you have sender. then you need to just make custom subclass of the control you are using and create a custom property like this.
#property (nonatomic, strong)NSIndexPath *indexPath;
and set it in cellforrowatindexpath. when your selecter calls the you can parse the sender to the custom created class and get the value form the property. try this once.
I have a UITableViewController with a few UITableViewCells. Each of those cells have different content: labels, images, text fields, etc. I am needing to set those pieces of content based around user interaction of my UITableViewController class.
For example: I have a cell that has a UIImageView in it. When a user taps the UIImageView I want a UIActionSheet to show. The problem is that I can't set the UIImageView as a property of the UITableViewController class. It has to be a property of a subclass for that UITableViewCell. But that means if I write the code for showing a UIActionSheet in the UITableViewCell subclass it won't show in the UITableViewController class.
My question is, since all of the objects within a UITableViewCell are in their own class, how do I alert the UITableViewController class when one of those actions happens?
Also, when a UIActionSheet is displayed it will need to show up in my UITableViewController class. If I select an item from that action sheet and wanted the text of that item to propagate to setting the text of a UILabel inside of a UITableViewCell, how could I do that?
With UITableViewController you control everything through the UITableViewDataSource implementation.
Here is how you can do it: make an int field in your model class that says which row needs to show a cell with an action sheet, and set it to -1 (which means that no cell needs to show an action sheet). In the tap handler of the UIImageView call the model, and tell it that the cell to which the UIImageView belongs needs an action sheet now. At that point you tell your UITableView to reload data. This sets the whole system in motion again - the table view calls back your data source to ask for the count, and then it calls again for the cell. This is when your data source looks at the model, sees that the row needs an action sheet, and returns a subclass of the cell with the action sheet visible.
Here is a diagram showing this sequence of events.
When the user is done with the action sheet, the action sheet needs to call the model again, and tell it that the action sheet is no longer needed (i.e. set the index back to -1). After that it should call the table view again, and tell it to reload the data. The sequence will repeat again, but is time there will be no flag asking for the action sheet, so a regular cell would be returned.
well, I simply suggest to use Custom UITableView Cell class, so when you are creating custom cell pass your uitableview controller to that class as parent. Now in your Custom Class you can add singleTap on UIImageView, from tap on it you can call method in your parent class via parent object you have already passed.
I have a TableView with a prototype cell, in this prototype cell I have a text field.
Well, what I need is to get a information from this text field when it changes. I need to do that to feed a object.
How can I do that ?
Short answer - you don't.
Slightly more in depth answer...
The UITableViewCell is a "view". It is used only to display information to the screen. It is not for storing data in.
In a UITableViewController (i.e. UITableViewDatasource and UITableViewDelegate) you should have an NSArray (or an NSFetchedResultsController) that you use to store information in.
Then using the delegate methods you populate the table with that data.
If the data changes then you reload the table to update the cells.
What should never happen is the following...
Load the table by passing in data to the cell.
The cell then changes its own data and changes what is on the screen.
The controller then reads that data out of the cell.
No, no, no, don't do this.
What should happen is this...
Load the table and configure the cell display to represent the correct part of the data.
When the button is pressed (or text field is changed, etc...) in the cell then call a method back in the controller to update the data accordingly.
Now the data has changed, reload the cell.
It will now show the correct information based on the new data.
You need a custom cell with a public property (IBOutlet) UITextfield. Then you can set your ViewController as the textField's delegate in cellForRowAtIndexPath,
cell.textField.delegate = self;
in this case your ViewController has to implement UITextFieldDelegate protocol.