I was wondering if anyone had had any luck triggering scrollsToTop (or by any other means) for a UITableView from the user tapping on the status bar when the UITableView is nested inside a UIScrollView that also observes this function? I know this probably isn't good practice, but in this instance the UX kind of calls for this type of hierarchy.
Either way, I've seen a whole bunch of proposals ranging from private methods (obviously not going to happen) to adding fake windows over the status bar (also not going to happen).
Ok, so the answer here is two fold:
You cannot have more than one UIScrollView (or classes deriving from or using UIScrollView - i.e. UITableView) on the same UIView with the property scrollsToTop set to YES. Pick the one you want to have the feature and make sure all others are no
For example, do this:
scrollView.scrollsToTop = NO;
tableView.scrollsToTop = YES; // or not set
Implement the UIScrollView delegate method scrollViewShouldScrollToTop: and return YES if the calling UIScrollView is the UITableView.
Props to this answer for mentioning the non-multiple scrollsToTop option.
Just wanted to share a little function I wrote that helps debug these situations. As others have mentioned, you have to make sure only ONE scroll view has scrollsToTop turned on. If you embed complex view hierarchies it may be difficult to figure out which scroll view is the culprit. Just call this method after your view hierarchy is created, like in viewDidAppear. The level parameter is just to help indentation and you should seed it off with 0.
-(void)inspectViewAndSubViews:(UIView*) v level:(int)level {
NSMutableString* str = [NSMutableString string];
for (int i = 0; i < level; i++) {
[str appendString:#" "];
}
[str appendFormat:#"%#", [v class]];
if ([v isKindOfClass:[UITableView class]]) {
[str appendString:#" : UITableView "];
}
if ([v isKindOfClass:[UIScrollView class]]) {
[str appendString:#" : UIScrollView "];
UIScrollView* scrollView = (UIScrollView*)v;
if (scrollView.scrollsToTop) {
[str appendString:#" >>>scrollsToTop<<<<"];
}
}
NSLog(#"%#", str);
for (UIView* sv in [v subviews]) {
[self inspectViewAndSubViews:sv level:level+1];
}}
Call it on your view controller's main view.
In the log, you should see >>>scrollsToTop<<< next to every view that has it turned on, making it easy to find the bug.
One thing that helped me fix this problem is:
Verify every other UIScollView in the view hierarchy is set to scollsToTop = NO
Add sub view controllers as child view controllers using addChildViewController: It is not sufficient to only add the view as a subview to the parent's view.
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[gridTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionNone animated:YES];
Hoping to help you :)
Related
First of all, thank God for Stack Overflow. I am new at Objective-C/Cocoa/iPhone development, and this site is an amazing aid.
I have a window, that has a tab bar, that has a navigation controller, that loads a UITableViewController. Clicking an "Add" button on the navigation bar (created programatically), pushes a UITableViewController like so:
InvoiceAddViewController *addController = [[InvoiceAddViewController alloc] initWithNibName:#"InvoiceAddViewController" bundle:[NSBundle mainBundle]];
[self.navigationController pushViewController:addController animated:YES];
This table view controller pushes it's own detail view:
UITableViewCell *targetCell = [self.tableView cellForRowAtIndexPath:indexPath];
GenericTextFieldDetailViewController *dvController = [[GenericTextFieldDetailViewController alloc] initWithNibName:#"GenericTextFieldDetailViewController" bundle:[NSBundle mainBundle]];
dvController.fieldName = targetCell.textLabel.text;
dvController.fieldValue = targetCell.detailTextLabel.text;
[self.navigationController pushViewController:dvController animated:YES];
[dvController release];
The concept being, you click on a cell in the table view controller such as "Notes". This pushes "GenericTextFieldDetailViewController" with the name of the "field" you clicked, and the value (if one already exists). This allows me to reuse my detail view rather than creating one ad nauseum for every field.
In order to push data back, I created a method on the "Add" UITableViewController:
- (void) updateField:(NSString*) fieldName value:(NSString*) fieldValue
{
UITableViewCell *targetCell;
if([fieldName isEqualToString:#"Invoice ID"])
{
NSUInteger indexArr[] = {1,1};
targetCell = [[self tableView] cellForRowAtIndexPath:[NSIndexPath indexPathWithIndexes:indexArr length:2]];
targetCell.detailTextLabel.text = fieldValue;
}
else if([fieldName isEqualToString:#"P.O. Number"])
{
NSUInteger indexArr[] = {1,2};
targetCell = [[self tableView] cellForRowAtIndexPath:[NSIndexPath indexPathWithIndexes:indexArr length:2]];
targetCell.detailTextLabel.text = fieldValue;
}
else if([fieldName isEqualToString:#"Add Note"])
{
NSUInteger indexArr[] = {3,0};
targetCell = [[self tableView] cellForRowAtIndexPath:[NSIndexPath indexPathWithIndexes:indexArr length:2]];
targetCell.detailTextLabel.text = fieldValue;
}
}
This method is designed to receive the data I push with this method in "Generic":
- (IBAction)saveField:(id)sender
{
self.fieldValue = theTextField.text;
InvoiceAddViewController *parentController = (InvoiceAddViewController*)self.view.superview;
[parentController updateField:self.fieldName value:self.fieldValue];
}
Which brings us to the problem:
When the save method fires off, it throws an invalid selector error because self.view.superview is not the UITableView that pushed the "Generic" detail view.
I have tried the following combinations (from GDB):
(gdb) po [[self view] superview]
<UIViewControllerWrapperView: 0x4b6d4d0; frame = (0 64; 320 367); autoresize = W+H; layer = <CALayer: 0x4b6c090>>
(gdb) po [[self navigationController] parentViewController]
<UITabBarController: 0x4d2fa90>
(gdb) po [self parentViewController]
<UINavigationController: 0x4d2fdc0>
I feel like I'm landing all around the UITableView I want to invoke, but can't find it.
What am I doing wrong?
refrains from pulling more hair out
The problem is that you are confusing the view hierarchy for the navigation stack. Your detail view controller wants to send a message to the controller that pushed it on the stack, which is the second to last object in the navigation controller's viewControllers array.
Try changing your saveField: method to:
- (IBAction)saveField:(id)sender
{
self.fieldValue = theTextField.text;
NSArray *navigationStack = self.navigationController.viewControllers;
InvoiceAddViewController *parentController = (InvoiceAddViewController*)[navigationStack objectAtIndex:navigationSTack.count - 2];
[parentController updateField:self.fieldName value:self.fieldValue];
}
Edit: I should note this design is very brittle. A better way is to apply Model-View-Controller and create an object that represents a field and title value. Then your InvoiceAddViewController can pass instances of these objects to your detail controller, and as your detail controller changes them, these changes can be easily reflected in your other controllers.
Edit 2: Here is a hint of how it will work.
UITableViewCell *targetCell = [self.tableView cellForRowAtIndexPath:indexPath];
GenericTextFieldDetailViewController *dvController = [[GenericTextFieldDetailViewController alloc] initWithNibName:#"GenericTextFieldDetailViewController" bundle:[NSBundle mainBundle]];
dvController.dataObject = [self dataObjectForIndexPath:indexPath];
[self.navigationController pushViewController:dvController animated:YES];
[dvController release];
I'm assuming you've implemented a dataObjectForIndexPath: method. Presumably tableView:cellForRowAtIndexPath: would also use this method to configure its cells.
Now, you can eliminate both the saveField: and updateField: methods. In your InvoiceAddViewController, viewWillAppear: could be used to refresh your view like this:
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self.tableView reloadData]
}
There is a whole world of possibilities here. reloadData is a very heavy handed one. Experiment with stuff like KVO to make this more automatic.
In your detail controller, of course, don't forget to update the object, say in view will disappear to do something like
self.dataObject.fieldValue = theTextField.text;
This is just to get you started. There are a lot of possibilities and details to consider. You should really look at a lot of sample code, this pattern gets used a lot. The CoreDataBooks example on the developer portal uses a similar pattern, there are almost certainly others.
You should be using notification, KVO or delegation. Use the design patterns that fit passing data around on this platform. Even if you can make the call directly using one of these patterns will most certainly prove to be a better way of achieving this in an encapsulated manner.
This approach is just screaming for delegation. Create a protocol on your detail view controller, declare a method like
- (void)genericTextFieldDetailViewController:(GenericTextFieldDetailViewController *)controller didUpdateValue:(NSString *)value forField:(NSString *)field
Now when you're ready to send the data back you would just send this message to your delegate (the pushing view controller).
[self.delegate genericTextFieldDetailViewController:self didUpdateValue:newValue forField:passedInField]
Make sure that when you create and push the detail view controller, you assign yourself as it's delegate and implement that method to handle the incoming values. I think you'll find this approach way more flexible.
I had the same problem, but not with a table view. I wanted to change a value from the last view controller. I'm using Xcode 5:
{
NSArray *navigationStack = self.navigationController.viewControllers;
ViewController *control = [navigationStack objectAtIndex:([navigationStack count] -2)];
control.pagecont.currentPage = self.currIndex;
CGRect frame;
frame.origin.x = control.scroll.frame.size.width * control.pagecont.currentPage;
frame.origin.y = 50;
frame.size = control.scroll.frame.size;
[control.scroll scrollRectToVisible:frame animated:YES];
[self.navigationController popViewControllerAnimated:YES];
}
I have a DetailsViewController class and an ItemsViewController class. (Both derived from UITableViewController)
Selecting any of the items in the ItemsViewController brings up the DetailsViewController. In order to get it to show the new data on any but the first one, I currently have
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[[self navigationItem] setTitle:title];
[[self tableView] reloadData];
}
This works, however it feels like killing a fly with a sledgehammer. What is a better way to do this?
Thanks in advance,
Alan
Combining ideas from several comments here:
Adding BOOL needReload as a member variable to the Details Controller.
Then in the details controller:
- (void)setData:(DataClass *)value {
if (value == data)
return;
id pointer = data;
data = [value retain];
[pointer release]; // release after retain
needReload = TRUE;
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
if(needReload){
[[self navigationItem] setTitle:title];
[[self tableView] reloadData];
needReload = FALSE;
}
}
If you know that only specific rows or sections will be changing, you can direct the bake view to reload only those rows or sections. Other than that, -reloadData is the way to go for most table views.
I assume the items on the detail table changes depending on the selected item on the items table. So, yeah, this should be alright.
Other than that, you can check if the same item is selected the last time and not call reloadData during that case.
Alan,
Your statement of "In order to get it to show the new data on any but the first one" concerns me - because it tells me that you likely have a single DetailsViewController instance.
In your first table view, ItemsViewController, you probably have a didSelectRowAtIndexPath: method that you're using to push the DetailsViewController onto the UINavigationController stack.
How I solve this issue is simply creating/destroying a new DetailsViewController every time my user taps between views. So, my didSelectRowAtIndexPath: often looks like:
- (void) didSelectRowAtIndexPath:(NSIndexPath*)indexPath
{
NSInteger selectedRow = indexPath.row;
// Create a new view controller
DetailsViewController *tmpVC = [[DetailsViewController alloc] initWithNibName:#"foo" bundle:nil];
// Tell our new view controller what data it should be using
tmpVC.tableData = [self.someArrayOfData objectAtIndex:selectedRow];
// Push view controller and release it
[self.navigationController pushViewController:tmpVC animated:YES];
[tmpVC release];
}
This example assumes that you have all the data necessary for both view controllers in your ItemsViewController - that may not be the case..?
Anyway, by doing it this way, your DetailsViewController automatically loads the data. When you tap "Back" to go back to ItemsViewController, the UINavigationController would release it, destroying it. Then, when the user taps a different cell, we run this code again, creating a brand-new controller with brand-new data - so of course when it displays, it will load the data automatically - it's never displayed before.
What it sounds like you may be doing in your code is retaining the DetailsViewController as a property of the ItemsViewController class and then reusing the object. This can also work as well if you're concerned about allocations (for example, if it is a very "heavy" allocation to make a DetailsViewController), but then I think the best place to call reloadData is not inside the class itself - but rather from the didSelectRowAtIndexPath: method of ItemsViewController.
The reason I promote the creation/destruction approach as opposed to the "flyweight pattern" approach is that it keeps your code more separate - the fewer linkages between view controllers, the better. Of course, ItemsViewController will always dependo on and know about DetailsViewController, but it shouldn't necessarily have to be the other way around - and if you add the reloadData call to viewWillAppear:animated:, you're implicitly adding a non-code dependency between the two. You know that when ItemsViewController is the "parent" in the navigation stack, that's the right behavior -- but what if you suddenly started reusing that view in other part of your app that doesn't require a reload? It's a performance hit for one, and moreover, it's the kind of hidden dependency that may end up in a nasty-to-trace bug someday. So, I'd keep Details stupid and make Items contain all the complexity, if it is indeed required to only have 1 DetailsViewController (as opposed to my first idea of recreating it each time).
I would propose the reloadData and setTitle to be in the viewDidLoad and in the setter - I assume you set a property in DetailsViewController that changes the datasource of the table. So viewDidLoad reloads and sets the title, if the property has been set, the setter reloads and sets the title if isViewLoaded and the new value is different than the old one.
- (void)setSmth:(SmthClass *)value {
if (value == smth) // if they are the same and SmthClass is immutable,
// otherwise use isEqual and [self.tableView reloadData]
// before returning...
return;
id pointer = smth; // if it's a retain property
smth = [value retain];
[pointer release]; // release after retain just to be extra safe
if ([self isViewLoaded]) {
[self.tableView reloadData];
[self setTitle:title];
}
}
- (void)viewDidLoad {
if (smth) {
[self.tableView reloadData]; // maybe redundant...
[self setTitle:title];
}
}
Or you can use Key-Value observing (NSKeyValueObserving protocol) to observe your property and reloadData on notification...
I have UITableView that has custom cells representing a player. When I add a new player a new data item is added and [UITableView reloadData] is called. Part of the cell is a UITextView that contains the player name. How do I bring up the keyboard for it?
I could use becomeFirstResponder if I had access to the UITextView in the cell but it hasn't actually been created yet. Since reloadData doesn't actually perform the creation of the new cell yet.
Someone else must have solved this before. Any tips on how to make this work would be greatly appreciated.
My code snippet:
-(IBAction)addPlayerPressed:(id)sender {
Player *newPlayer = [Player alloc];
[players addObject:newPlayer];
[table reloadData];
// Scroll to bottom showing the new player location
NSIndexPath *scrollIndexPath = [NSIndexPath indexPathForRow:([players count] - 1) inSection:0];
[table scrollToRowAtIndexPath:scrollIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
// cell is always nil :(
PlayerTableCell *cell = (PlayerTableCell *)[table cellForRowAtIndexPath:scrollIndexPath]
[cell.nameField setfirstResponder];
}
Couple of things:
You're probably better off using UITextField instead of UITextView. UITextField is ideal for single-line entries like what you're describing here.
As for your solution, here's what I'd recommend. (Also took the liberty of cleaning up your memory management)
Add a BOOL property to your Player class called, say, 'needsKeyboardDisplay', then set it to yes after you create a new instance.
-(IBAction)addPlayerPressed:(id)sender {
Player *newPlayer = [[Player alloc] init];
newPlayer.needsKeyboardDisplay = YES;
[players addObject:newPlayer];
[newPlayer release];
[table reloadData];
// Scroll to bottom showing the new player location
NSIndexPath *scrollIndexPath = [NSIndexPath indexPathForRow:([players count] - 1) inSection:0];
[table scrollToRowAtIndexPath:scrollIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
}
Then, in cellForRowAtIndexPath, insert this after you've otherwise set up your cell:
Player *playerForCell = [players objectAtIndex:indexPath.row];
if (playerForCell.needsKeyboardDisplay)
{
[nameField becomeFirstResponder];
playerForCell.needsKeyboardDisplay = NO;
}
All that said, from a user experience perspective, the (somewhat) standard iPhone experience for editing details in a long list of items like this is to do it in another view. It's up to you, of course.
Can u make the textfields delegate the UITableviewController instead Of the table cell?
In your PlayerTableCell class, you could put some code in the -initWithStyle:reuseIdentifier: method that checks to see if it should become first responder and, if so, does. You probably have a PlayerController class or something similar; you might make a delegate method for the custom table view cell or something similar.
I'm using an tableView with custom cells. When I want to display another view using the pushViewController function of the navigationController I loop through the textfields and call resignFirstResponder on them. But resignFirstResponder does only work when the textfields are being displayed so I scroll first to the top of the page. This is the code:
NSIndexPath *topIndexPath;
topIndexPath = [[NSIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0];
[self.tableView scrollToRowAtIndexPath:topIndexPath atScrollPosition:UITableViewScrollPositionTop animated:NO];
[[self textFieldForRow:0] resignFirstResponder];
[[self textFieldForRow:1] resignFirstResponder];
[[self textFieldForRow:2] resignFirstResponder];
[[self textFieldForRow:3] resignFirstResponder];
This works, but after this my tableView has some weird problem with its origin. I tried to set it's superviews origin to 0, but that doesn't help.
Here is a screenshot of the problem: link
As you can see, my tableview is too large and the scrollbar is stuck in the middle of the view when reaching the bottom.
Sorry for my english, I hope that you can understand me,
Thanks in advance!
Hans
It was actually quite simple. Just put your resignFirstResponder in -viewWillDisappear
edit: this is better and less hacky, I added this to my class, and it worked:
edit 2: seems that your app will be rejected when using the previous code. Here is a updated public api version:
- (void)viewWillDisappear:(BOOL)animated
{
[self.view findAndResignFirstResponder];
}
And:
#implementation UIView (FindAndResignFirstResponder)
- (BOOL)findAndResignFirstResponder
{
if (self.isFirstResponder) {
[self resignFirstResponder];
return YES;
}
for (UIView *subView in self.subviews) {
if ([subView findAndResignFirstResponder])
return YES;
}
return NO;
}
#end
(source: Get the current first responder without using a private API)
I would fix your other problem. I imagine when you say you can't call "resignFirstResponder" when the other textFields are on screen, you mean that there is a crash?
If so, it is because of screen cells don't exist and therefore the textfields are gone as well. They are recycled (so they can be dequeued for new cells).
The easy solution is to only call resignFirstResponder only on textFields that ARE on screen.
What you are doing now seems a little hacky.
I have an application that, on load, displays a UITableView with the user's data in it.
However, when the user first loads the application (before they've created any data), I'd like to display, instead of an empty table, a background image (with an arrow pointing to the 'add a record' navbar button). Once the user has added their first record, the tableview is displayed instead. I've seen numerous apps do this - the only example I can think of at present is Cha-Ching, before you have any budgets/etc set up. I can't for the life of me work out how to do this, though.
I initially added a navigationcontroller to the main window's xib, the rootViewController of which was a custom viewcontroller/xib. This rootViewController contained the background image with a hidden tableview above it, and a custom tableviewcontroller that managed the tableview. This seemed to work just fine, and if there was data it would load and display in the table. However, if I was to scroll the data offscreen, the app would crash, with this error:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason:
'*** -[UITextEffectsWindow tableView:cellForRowAtIndexPath:]: unrecognized selector sent to instance 0xd2d130'
I have no clue what a UITextEffectsWindow is, or why it was trying to manage my tableview. I presume something may be hooked up incorrectly in my view hierarchy...
If there's a much simpler/more straightforward way of doing this, I'd be very grateful if someone could explain it. How would you do this?
Thanks in advance.
Here's one solution that I've been satisfied with so far.
First, I created a transparent view that was the same size as my TableView. I add this view as a sibling of the TableView whenever the TableView's data source has no data in it. I completely remove the view when data is available because transparency can affect the smoothness of the scrolling animation with TableViews.
I simply added a transparent label to this view that says something to the effect of "No Data Available". Adding a little shadowing to this label really helped to reinforce the concept that the label was 'floating' over top of the empty TableView.
I like your method of using an image though. Done properly, it might save you some work if you don't have to localize a string like I currently do.
To achieve this using a UITableViewController subclass as my only view (within a UINavigationController as per the Apple template) I used the following approach:
Create a UIView of the size of my tableView in the XIB that contains your UITableViewController and tableView.
Add an ImageView set with my placeholder image to the UIView.
Wire up the UIView as an IBOutlet (in the example code below, I called it emptyTableView)
When it is time to show the placeholder from within the UITableViewController subclass :
[self.tableView addSubView:emptyTableView];
[self.tableView setScrollEnabled:FALSE];
Disabling the scroll is necessary otherwise the user will be able to move the placeholder image up and down. Just remember to enable it once the user adds an item.
To remove the image view
[emptyTableView removeFromSuperview];
To do this, I use the following controller instead of UITableViewController. It will automatically place a view over the table when it is empty, and remove it when it is filled.
Just call [self reloadData] instead of [self.tableView reloadData] so that it can check if the table became empty.
In your subclass, implement a makeEmptyOverlayView function that will create the view to show over an empty table.
#interface MyTableViewController : UITableViewController
{
BOOL hasAppeared;
BOOL scrollWasEnabled;
UIView *emptyOverlay;
}
- (void) reloadData;
- (void) checkEmpty;
#end
#implementation MyTableViewController
- (void)viewWillAppear:(BOOL)animated
{
[self reloadData];
[super viewWillAppear: animated];
}
- (void)viewDidAppear:(BOOL)animated
{
hasAppeared = YES;
[super viewDidAppear: animated];
[self checkEmpty];
}
- (void)viewDidUnload
{
if (emptyOverlay)
{
self.tableView.scrollEnabled = scrollWasEnabled;
[emptyOverlay removeFromSuperview];
emptyOverlay = nil;
}
}
- (void) reloadData
{
[self.tableView reloadData];
if (hasAppeared &&
[self respondsToSelector: #selector(makeEmptyOverlayView)])
[self checkEmpty];
}
- (void) checkEmpty
{
BOOL isEmpty(YES);
id<UITableViewDataSource> src(self.tableView.dataSource);
NSInteger sections(1);
if ([src respondsToSelector: #selector(numberOfSectionsInTableView:)])
sections = [src numberOfSectionsInTableView: self.tableView];
for (int i(0); i<sections; ++i)
{
NSInteger rows([src tableView: self.tableView numberOfRowsInSection: i]);
if (rows)
isEmpty = NO;
}
if (!isEmpty != !emptyOverlay)
{
if (isEmpty)
{
scrollWasEnabled = self.tableView.scrollEnabled;
self.tableView.scrollEnabled = NO;
emptyOverlay = [self makeEmptyOverlayView];
[self.tableView addSubview: emptyOverlay];
[emptyOverlay release];
}
else
{
self.tableView.scrollEnabled = scrollWasEnabled;
[emptyOverlay removeFromSuperview];
emptyOverlay = nil;
}
}
else if (isEmpty)
{
// Make sure it is still above all siblings.
[emptyOverlay retain];
[emptyOverlay removeFromSuperview];
[self.tableView addSubview: emptyOverlay];
[emptyOverlay release];
}
}
#end
If you use Three20, you can easily set any image you want as a place holder prior to your table being populated.
So, to solve this I did as discussed in the comments above:
I created a normal UIViewController subclass, which contained a UIImageView and a UITableView. The viewController conforms to the UITableViewDelegate and UITableViewDatasource protocols, and looks after the tableView. The viewController class simply shows or hides the imageView depending on whether data is available.
I was going wrong before by trying to manage both these views with a UITableViewController. A UITableViewController has to have a tableView as its view, whereas, with this solution, a viewController can contain both the image and the tableView, and implement the necessary protocols to manage the tableView.
Thanks for all the help!