iOS - Resize multiple views with touch-drag separators - ios

How can I resize views with a separator? What I'm trying to do is something like Instagram layout app. I want to be able to resize views by dragging the line that separates the views.
I already looked into this question. It is similar to what I want to accomplish and I already tried the answers but it does not work if there are more than 2 views connected to a separator (if there are 3 or more view only 2 views resize when separator moves each time). I tried to change the code but I have no idea what to do or what the code means.
In my app I will have 2-6 views. The separator should resize all the views that is next to it.
Some examples of my views:
How can I accomplish this? Where do I start?

There are lots of ways to accomplish this, but like Avinash, I'd suggest creating a "separator view" in between the various "content" UIView objects. Then you can drag that around. The trick here, though, is that you likely want the separator view to be bigger than just the narrow visible line, so that it will capture touches not only right on the separator line, but close to it, too.
Unlike that other answer you reference, nowadays I'd new recommend using autolayout so that all you need to do with the user gestures is update the location of the separator view (e.g. update the top constraint of the separator view), and then all of the other views will be automatically resized for you. I'd also suggest adding a low priority constraint on the size of the subviews, so that they're laid out nicely when you first set everything up and before you start dragging separators around, but that it will fail gracefully when the dragged separator dictates that the size of the neighboring views must change.
Finally, while we'd historically use gesture recognizers for stuff like this, with the advent of predicted touches in iOS 9, I'd suggest just implementing touchesBegan, touchesMoved, etc. Using predicted touches, you won't notice the difference on the simulator or older devices, but when you run this on a device capable of predicted touches (e.g. new devices like the iPad Pro and other new devices), you'll get a more responsive UX.
So a horizontal separator view class might look like the following.
static CGFloat const kTotalHeight = 44; // the total height of the separator (including parts that are not visible
static CGFloat const kVisibleHeight = 2; // the height of the visible portion of the separator
static CGFloat const kMargin = (kTotalHeight - kVisibleHeight) / 2.0; // the height of the non-visible portions of the separator (i.e. above and below the visible portion)
static CGFloat const kMinHeight = 10; // the minimum height allowed for views above and below the separator
/** Horizontal separator view
#note This renders a separator view, but the view is larger than the visible separator
line that you see on the device so that it can receive touches when the user starts
touching very near the visible separator. You always want to allow some margin when
trying to touch something very narrow, such as a separator line.
*/
#interface HorizontalSeparatorView : UIView
#property (nonatomic, strong) NSLayoutConstraint *topConstraint; // the constraint that dictates the vertical position of the separator
#property (nonatomic, weak) UIView *firstView; // the view above the separator
#property (nonatomic, weak) UIView *secondView; // the view below the separator
// some properties used for handling the touches
#property (nonatomic) CGFloat oldY; // the position of the separator before the gesture started
#property (nonatomic) CGPoint firstTouch; // the position where the drag gesture started
#end
#implementation HorizontalSeparatorView
#pragma mark - Configuration
/** Add a separator between views
This creates the separator view; adds it to the view hierarchy; adds the constraint for height;
adds the constraints for leading/trailing with respect to its superview; and adds the constraints
the relation to the views above and below
#param firstView The UIView above the separator
#param secondView The UIView below the separator
#returns The separator UIView
*/
+ (instancetype)addSeparatorBetweenView:(UIView *)firstView secondView:(UIView *)secondView {
HorizontalSeparatorView *separator = [[self alloc] init];
[firstView.superview addSubview:separator];
separator.firstView = firstView;
separator.secondView = secondView;
[NSLayoutConstraint activateConstraints:#[
[separator.heightAnchor constraintEqualToConstant:kTotalHeight],
[separator.superview.leadingAnchor constraintEqualToAnchor:separator.leadingAnchor],
[separator.superview.trailingAnchor constraintEqualToAnchor:separator.trailingAnchor],
[firstView.bottomAnchor constraintEqualToAnchor:separator.topAnchor constant:kMargin],
[secondView.topAnchor constraintEqualToAnchor:separator.bottomAnchor constant:-kMargin],
]];
separator.topConstraint = [separator.topAnchor constraintEqualToAnchor:separator.superview.topAnchor constant:0]; // it doesn't matter what the constant is, because it hasn't been enabled
return separator;
}
- (instancetype)init {
self = [super init];
if (self) {
self.translatesAutoresizingMaskIntoConstraints = false;
self.userInteractionEnabled = true;
self.backgroundColor = [UIColor clearColor];
}
return self;
}
#pragma mark - Handle Touches
// When it first receives touches, save (a) where the view currently is; and (b) where the touch started
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.oldY = self.frame.origin.y;
self.firstTouch = [[touches anyObject] locationInView:self.superview];
self.topConstraint.constant = self.oldY;
self.topConstraint.active = true;
}
// When user drags finger, figure out what the new top constraint should be
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
// for more responsive UX, use predicted touches, if possible
if ([UIEvent instancesRespondToSelector:#selector(predictedTouchesForTouch:)]) {
UITouch *predictedTouch = [[event predictedTouchesForTouch:touch] lastObject];
if (predictedTouch) {
[self updateTopConstraintOnBasisOfTouch:predictedTouch];
return;
}
}
// if no predicted touch found, just use the touch provided
[self updateTopConstraintOnBasisOfTouch:touch];
}
// When touches are done, reset constraint on the basis of the final touch,
// (backing out any adjustment previously done with predicted touches, if any).
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self updateTopConstraintOnBasisOfTouch:[touches anyObject]];
}
/** Update top constraint of the separator view on the basis of a touch.
This updates the top constraint of the horizontal separator (which moves the visible separator).
Please note that this uses properties populated in touchesBegan, notably the `oldY` (where the
separator was before the touches began) and `firstTouch` (where these touches began).
#param touch The touch that dictates to where the separator should be moved.
*/
- (void)updateTopConstraintOnBasisOfTouch:(UITouch *)touch {
// calculate where separator should be moved to
CGFloat y = self.oldY + [touch locationInView:self.superview].y - self.firstTouch.y;
// make sure the views above and below are not too small
y = MAX(y, self.firstView.frame.origin.y + kMinHeight - kMargin);
y = MIN(y, self.secondView.frame.origin.y + self.secondView.frame.size.height - (kMargin + kMinHeight));
// set constraint
self.topConstraint.constant = y;
}
#pragma mark - Drawing
- (void)drawRect:(CGRect)rect {
CGRect separatorRect = CGRectMake(0, kMargin, self.bounds.size.width, kVisibleHeight);
UIBezierPath *path = [UIBezierPath bezierPathWithRect:separatorRect];
[[UIColor blackColor] set];
[path stroke];
[path fill];
}
#end
A vertical separator would probably look very similar, but I'll leave that exercise for you.
Anyway, you could use it like so:
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIView *previousContentView = nil;
for (NSInteger i = 0; i < 4; i++) {
UIView *contentView = [self addRandomColoredView];
[self.view.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor].active = true;
[self.view.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor].active = true;
if (previousContentView) {
[HorizontalSeparatorView addSeparatorBetweenView:previousContentView secondView:contentView];
NSLayoutConstraint *height = [contentView.heightAnchor constraintEqualToAnchor:previousContentView.heightAnchor];
height.priority = 250;
height.active = true;
} else {
[self.view.topAnchor constraintEqualToAnchor:contentView.topAnchor].active = true;
}
previousContentView = contentView;
}
[self.view.bottomAnchor constraintEqualToAnchor:previousContentView.bottomAnchor].active = true;
}
- (UIView *)addRandomColoredView {
UIView *someView = [[UIView alloc] init];
someView.translatesAutoresizingMaskIntoConstraints = false;
someView.backgroundColor = [UIColor colorWithRed:arc4random_uniform(256)/255.0 green:arc4random_uniform(256)/255.0 blue:arc4random_uniform(256)/255.0 alpha:1.0];
[self.view addSubview:someView];
return someView;
}
#end
That yields something like:
As I mentioned, a vertical separator would look very similar. If you have complicated views with both vertical and horizontal separators, you'd probably want to have invisible container views to isolate the vertical and horizontal views. For example, consider one of your examples:
That would probably consist of two views that span the entire width of the device with a single horizontal separator, and then the top view would, itself, have two subviews with one vertical separator and the bottom view would have three subviews with two vertical separators.
There's a lot here, so before you try extrapolating the above example to handle (a) vertical separators; and then (b) the views-within-views pattern, make sure you really understand how the above example works. This isn't intended as a generalized solution, but rather just to illustrate a pattern you might adopt. But hopefully this illustrates the basic idea.

I've updated #JULIIncognito's Swift class to Swift 4, added a drag indicator and fixed some typos.
SeparatorView
Just import in into your project and use it like so:
SeparatorView.addSeparatorBetweenViews(separatorType: .horizontal, primaryView: view1, secondaryView: view2, parentView: self.view)
This is how it looks like (MapView on top, TableView on bottom):

Base on Rob`s solution I created Swift class for both horizontal and vertical separator view:
https://gist.github.com/JULI-ya/1a7c293b022207bb427caa3bbb9d3ed8
There are code only for two inner views with separator, because my idea is to put each to other for creating this custom layout. It will look like a binary tree structure of views.

Use UIPanGestureRecognizers. Add a recognizer to each view. In gestureRecognizerShouldBegin: method return YES if the location of gesture is very close to the edge (use gesture's locationInView:view method). Then in gesture's action method (specified in gesture's initWithTarget: action:) you proccess your moves something like this:
-(void)viewPan:(UIPanGestureRecognizer *)sender
switch (sender.state) {
case UIGestureRecognizerStateBegan: {
//determine the second view based on gesture's locationInView:
//for instance if close to bottom, the second view is the one under the current.
}
case UIGestureRecognizerStateChanged: {
//change the frames of the current and the second view based on sender's translationInView:
}
...
}

As per my best knowledge we can do this using UIGestureRecognizer and auto layout.
1. Use UIView as line separator.
2. Add Pan gestureRecognizer to separator line view.
3. Handle view movement in delegate protocol methods using UIView.animatewithDuration()
PanGestureRecognizer
Most important, don't forget to set/Check UserInteration Enabled for all line separator view in Attribute Inspector.

Related

Expand UIScrollView interactive area and differentiate swiping and tapping

I'm using UIScroll View to make a gallery-like ui with paging functionality. Basically like this:
Since I need paging, so I set the width of scrollview equals to the width of a single page, in my example, the width of the pink rectangular.
But I want two extra things:
Tapping the yellow or blue area will bring the corresponding rectangular to the center.
One can scroll/swipe on yellow or blue area (out of the scrollview), which means the entire width of the screen is scrollable.
I followed this thread and added - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event. BUT by doing so, I can only achieve my second goal. When I set selector or delegate handling tapping reaction of yellow and blue, it does't work. Any idea about it?
That answer you referenced is one of my old favorites. It doesn't contemplate your first requirement, but I think it can handle it very neatly with just the addition of a tap gesture recognizer.
Create it on your "ClipView":
UITapGestureRecognizer *tapGR = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(tap:)];
[self.myClipView addGestureRecognizer:tapGR];
// myClipView is the view that contains the paging scroll view
- (void)tap: (UITapGestureRecognizer *)gr {
// there are a few challenges here:
// 1) get the tap location in the correct coordinate system
// 2) convert that to which "page" was tapped
// 3) scroll to that page
}
Challenge 1) is easy thanks to the gesture recognizer, which answer locationInView:
CGPoint location = [gr locationInView:self.scrollView];
For challenge 2) we need to work out what page within your scroll view was tapped. That can be done with pretty simple arithmetic given the page width.
// assuming you have something like this
#define kPAGE_WIDTH // some float
// page is just how many page-width's are represented by location.y
NSInteger page = floor(location.y/kPAGE_WIDTH);
Now, challenge 3) is easy now because we can change a page to it's scroll position straight-forwardly...
CGFloat y = page * kPAGE_WIDTH;
[self.scrollView setContentOffset:CGPointMake(y, 0.0f) animated:YES];
Or, all in one chunk of code...
- (void)tap: (UITapGestureRecognizer *)gr {
CGPoint location = [gr locationInView:self.scrollView];
NSInteger page = floor(location.y/kPAGE_WIDTH);
CGFloat y = page * kPAGE_WIDTH;
[self.scrollView setContentOffset:CGPointMake(y, 0.0f) animated:YES];
}
EDIT
You may also want to exclude the "current page" area from the gesture recognizer. That's simply done by qualifying the test in the tap method.
The only trick is to get the tap position in the same coordinate system as the scroll view's frame, that is, the clip view...
CGPoint locationInClipper = [gr locationInView:gr.view];
And the SDK provides a nice method to test...
BOOL inScrollView = [self.scrollView pointInside:locationInClipper withEvent:nil];
So...
- (void)tap: (UITapGestureRecognizer *)gr {
CGPoint locationInClipper = [gr locationInView:gr.view];
BOOL inScrollView = [self.scrollView pointInside:locationInClipper withEvent:nil];
if (!inScrollView) {
CGPoint location = [gr locationInView:self.scrollView];
NSInteger page = floor(location.y/kPAGE_WIDTH);
CGFloat y = page * kPAGE_WIDTH;
[self.scrollView setContentOffset:CGPointMake(y, 0.0f) animated:YES];
}
}

iOS - How do I know the view that is under the one I'm dragging?

I have a UIScrollView filled with draggable (UIView) cards as subviews and I want the cards to re-organize themselves (make space for new when the user drags one of them into the UIScrollView.
The problem is: how do I know which of the UIViews is under the one I'm dragging, so I can get its index and move it away from the card being dragged?
I tried using hitTest:withEvent: but I think I'm far from doing it right, since it's returning nil.
UIView *viewUnderCard = [card hitTest:card.center withEvent:nil];
Just started developing for iOS. Any help please?
You're on the right track, hitTest:withEvent: can be used. But #Mysiaq is correct that pointInside:withEvent: is probably even better.
You need to make sure that the coordinates are relative to the correct view. If you're using card.center, the coordinate system is that of the card's parent view.
The code could look something like this:
UIView *container = viewThatHasAllTheCards;
UIView *targetCard = nil;
CGPoint cardInWindow = [draggedCard.superview convertPoint:draggedCard.center toView:nil];
CGPoint cardInContainer = [container convertPoint:cardInWindow fromView:nil];
for (UIView *subview in container.subviews) {
if (subview == draggedCard) {
// Skip the dragged card.
continue;
}
if ([subview pointInside:cardInContainer withEvent:nil]) {
targetCard = subview;
// If you want the lower-most card, break here.
// If you want the top-most card, do not break here.
}
}
Get point of your touch and then call function
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
for all UIScrollView subviews and function will return you YES if provided CGPoint is inside of its frame.
You can compare bounding rects of two views:
CGRect boundsView1 = [view1 convertRect:view1.bounds toView:nil];
CGRect boundsView2 = [view2 convertRect:view2.bounds toView:nil];
Boolean viewsOverlap = CGRectIntersectsRect(boundsView1, boundsView2);
From here, you should be able to figure out how to iterate efficiently through your list of views to determine if any overlap.

Horizontally center multiple UIViews

I want to horizontally center a number of UIViews (they happen to be circles) in the master UIView. It will end up basically looking like the dots on the standard Page Control.
I have all the code written to create the circle UIViews I just have no idea how to arrange them horizontally and dynamically at run time.
Essentially I need some kind of horizontal container where I can do this
-(void)addCircle{
[self addSubView:[CircleView init]];
}
And it will auto arrange however many children it has in the center.
I get confused with auto-layout as well from time to time but here is a way how you can do it programmatically: (I assume that you add your circle views to a containerView property of your view controller and you do not add any other views to it.)
Add these two properties to your view controller:
#property (nonatomic) CGRect circleViewFrame;
#property (nonatomic) CGFloat delta;
Initiate those properties with the desired values in your view controller's viewDidLoad method:
// the size (frame) of your circle views
self.circleViewFrame = CGRectMake(0, 0, 10, 10);
// the horizontal distance between your circle views
self.delta = 10.0;
Now we add your "automatic addCircle method":
- (void)addCircleView {
UIView *newCircleView = [self createCircleView];
[self.containerView addSubview:newCircleView];
[self alignCircleViews];
}
Of course we need to implement the createCircleView method...
- (UIView*)createCircleView {
// Create your circle view here - I use a simple square view as an example
UIView *circleView = [[UIView alloc] initWithFrame:self.circleViewFrame];
// Set the backgroundColor to some solid color so you can see the view :)
circleView.backgroundColor = [UIColor redColor];
return circleView;
}
... and the alignCircleViews method:
- (void)alignCircleViews {
int numberOfSubviews = [self.containerView.subviews count];
CGFloat totalWidth = (numberOfSubviews * self.circleViewFrame.size.width) + (numberOfSubviews - 1) * self.delta;
CGFloat x = (self.containerView.frame.size.width / 2) - (totalWidth / 2);
for (int i = 0; i < numberOfSubviews; i++) {
UIView *circleView = self.containerView.subviews[i];
circleView.frame = CGRectMake(x,
self.circleViewFrame.origin.y,
self.circleViewFrame.size.width,
self.circleViewFrame.size.height);
x += self.circleViewFrame.size.width + self.delta;
}
}
This is the most important method which will automatically realign all your subviews each time a new circleView is added. The result will look like this:
Simple steps: append circle to container view, resize container view, center align container view
-(void)addToContanerView:(CircleView*)circle{
circle.rect.frame = CGrectMake(containers_end,container_y,no_change,no_change);
[containerView addSubview:circle];
[containerView sizeToFit];
containerView.center = self.view.center;
}
Assumptions:
containers_end & containers_y you can get from CGRectMax function,
for UIView SizeToFit method check here
To take care of rotation use make sure your Autoresizing subviews are set for left, right bottom and top margin.
You can try using this library. I have used it on several of my projects and so far, it worked really well.
https://github.com/davamale/DMHorizontalView

When moving a UILabel vertically with a UIPanGestureRecognizer, how do I stop them from going too far in either direction?

I'm trying to make the user be able to move the UILabel up and down across the view by attaching a UIPanGestureRecognizer to the UILabel and subsequently altering the constant of a constraint from the UILabel to the top of its view. So basically if the gesture recognizer detects them moving down 12pts, move the constant of the constraint 12pts to move the UILabel.
However, I want them to be stopped from moving further when they hit a certain vertical point (both too high or too low). I could just check the translation of the pan gesture, but my UILabel can be any number of lines, so if it's five lines instead of one, obviously it can't be panned down quite as far, so I can't rely on the translation of the pan gesture, I have to take into account the size of the label.
So I started monitoring its frame, and it works well, but in my implementation there's an annoying result where if they pan completely to the bottom limit, they have to pan really far back up before the UILabel "catches up" and comes with it (though no such problem exists when they hit the top boundary). Basically, they pan down to the bottom limit, and when they pan back up (this is all in the same gesture) it "sticks" momentarily until they pan far enough up, then it jumps up with their finger.
Here's the code I'm using to accomplish this:
- (void)textLabelPanned:(UIPanGestureRecognizer *)panGestureRecognizer {
if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
_textDistanceFromTopBeforeMove = self.textToReadLabelPositionFromTopConstraint.constant;
}
else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
NSNumber *textDistanceFromTop = #(self.textToReadLabelPositionFromTopConstraint.constant);
[[NSUserDefaults standardUserDefaults] setObject:textDistanceFromTop forKey:#"TextDistanceFromTop"];
}
else {
if (CGRectGetMinY(self.textToReadLabel.frame) >= [UIScreen mainScreen].bounds.origin.y + CLOSEST_TEXT_DISTANCE_TO_TOP && CGRectGetMaxY(self.textToReadLabel.frame) <= [UIScreen mainScreen].bounds.size.height - CLOSEST_TEXT_DISTANCE_TO_BOTTOM) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
else if ([panGestureRecognizer translationInView:self.mainView].y > 0) {
if (CGRectGetMaxY(self.textToReadLabel.frame) + _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y < [UIScreen mainScreen].bounds.size.height - CLOSEST_TEXT_DISTANCE_TO_BOTTOM) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
}
else if ([panGestureRecognizer translationInView:self.mainView].y < 0) {
if (CGRectGetMinY(self.textToReadLabel.frame) + _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y > [UIScreen mainScreen].bounds.origin.y + CLOSEST_TEXT_DISTANCE_TO_TOP) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
}
// If one of the options views are present and the user pans really low, hide the options as to allow the user to see where they're panning
if (_inSpeedChangingMode) {
if (CGRectGetMaxY(self.textToReadLabel.frame) > CGRectGetMinY(self.progressBar.frame) - 10) {
[self showWordOptions:nil];
}
}
else if (_inTextChangingMode) {
if (CGRectGetMaxY(self.textToReadLabel.frame) > CGRectGetMinY(self.progressBar.frame) - 10) {
[self showTextOptions:nil];
}
}
}
}
What exactly am I doing wrong that would be causing it to "stick"? And is there perhaps a better way to be doing this?
You can accomplish this entirely with constraints, defined either in Interface Builder, or in code. The trick is to define constraints that prevent the label from moving out of bounds that have higher priority than the constraints that set the desired position.
In my test project I set up a view hierarchy entirely in a storyboard having a 1) view controller view 2) "container view" which defines the bounds 3) multi-line UILabel. There are 6 constraints acting on the label from its container:
4 'space to' constraints (leading, trailing, top, bottom) prevent the label from ever being positioned outside the bounds of its parent container. The priority on these is set to the default '1000' value in Interface Builder. The relation for these constraints is '>=', and the constant value is '0'.
2 'space to' constraints (leading, top) drive the label's actual position. The priority on these is set lower; I chose '500'. These constraints have outlets in the view controller so they can be adjusted in code. The relation for these constraints is '=', and the initial value is whatever you want to position the label.
The label itself has a width constraint to force it to display with multiple lines.
Here's what this looks like in IB:
The selected constraint has a lower priority and is used to drive the x position of the label. This constraint is tied to an ivar in the view controller so it can be adjusted at runtime.
The selected constraint has a higher priority and is used to corral the label within its parent view.
And here is the code in the view controller:
#interface TSViewController ()
#end
#implementation TSViewController
{
IBOutlet NSLayoutConstraint* _xLayoutConstraint;
IBOutlet NSLayoutConstraint* _yLayoutConstraint;
}
- (IBAction) pan: (UIGestureRecognizer*) pgr
{
CGPoint p = [pgr locationInView: self.view];
p.x -= pgr.view.frame.size.width / 2.0;
p.y -= pgr.view.frame.size.height / 2.0;
_xLayoutConstraint.constant = p.x;
_yLayoutConstraint.constant = p.y;
}
#end
The UIPanGestureRecognizer is associated with the UILabel and has its callback set to the pan: method in the view controller.
If your app has minimum SDK iOS7, you can use UIKit Dynamics instead of those UIGestureRecognizers. Your problem could be easily solved with a UICollisionBehavoir combined with an UIAttachmentBehavior
You might want to look into it. Here's the apple sample project on UIKit Dynamics:
https://developer.apple.com/library/IOS/samplecode/DynamicsCatalog/Introduction/Intro.html
Play around with it and you'll be amazed what you can do with so little code.
WWDC 2013 sessions:
- Getting Started with UIKit Dynamics
- Advanced Techniques with UIKit Dynamics

Why is the top portion of my UISegmentedControl not tappable?

While I was playing on my phone, I noticed that my UISegmentedControl was not very responsive. It would take 2 or more tries to make my taps register. So I decided to run my app in Simulator to more precisely probe what was wrong. By clicking dozens of times with my mouse, I determined that the top 25% of the UISegmentedControl does not respond (the portion is highlighted in red with Photoshop in the screenshot below). I am not aware of any invisible UIView that could be blocking it. Do you know how to make the entire control tappable?
self.segmentedControl = [[UISegmentedControl alloc] initWithItems:[NSArray arrayWithObjects:#"Uno", #"Dos", nil]];
self.segmentedControl.selectedSegmentIndex = 0;
[self.segmentedControl addTarget:self action:#selector(segmentedControlChanged:) forControlEvents:UIControlEventValueChanged];
self.segmentedControl.height = 32.0;
self.segmentedControl.width = 310.0;
self.segmentedControl.segmentedControlStyle = UISegmentedControlStyleBar;
self.segmentedControl.tintColor = [UIColor colorWithWhite:0.9 alpha:1.0];
self.segmentedControl.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
UIView* toolbar = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, HEADER_HEIGHT)];
toolbar.autoresizingMask = UIViewAutoresizingFlexibleWidth;
CAGradientLayer *gradient = [CAGradientLayer layer];
gradient.frame = CGRectMake(
toolbar.bounds.origin.x,
toolbar.bounds.origin.y,
// * 2 for enough slack when iPad rotates
toolbar.bounds.size.width * 2,
toolbar.bounds.size.height
);
gradient.colors = [NSArray arrayWithObjects:
(id)[[UIColor whiteColor] CGColor],
(id)[[UIColor
colorWithWhite:0.8
alpha:1.0
] CGColor
],
nil
];
[toolbar.layer insertSublayer:gradient atIndex:0];
toolbar.backgroundColor = [UIColor navigationBarShadowColor];
[toolbar addSubview:self.segmentedControl];
UIView* border = [[UIView alloc] initWithFrame:CGRectMake(0, HEADER_HEIGHT - 1, toolbar.width, 1)];
border.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
border.backgroundColor = [UIColor colorWithWhite:0.7 alpha:1.0];
border.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[toolbar addSubview:border];
[self.segmentedControl centerInParent];
self.tableView.tableHeaderView = toolbar;
http://scs.veetle.com/soget/session-thumbnails/5363e222d2e10/86a8dd984fcaddee339dd881544ecac7/5363e222d2e10_86a8dd984fcaddee339dd881544ecac7_20140509171623_536d6fd78f503_68_896x672.jpg
As already written in other answers, UINavigationBar grabs the touches made near the nav bar itself, but not because it has some subviews extended over the edges: this is not the reason.
If you log the whole view hierarchy, you will see that the UINavigationBar doesn't extends over the defined edges.
The reason why it receives the touches is another:
in UIKit, there are many "special cases", and this is one of them.
When you tap the screen, a process called "hit testing" starts. Starting from the first UIWindow, all views are asked to answer two "questions": is the point tapped inside your bounds? what is the subviews that must receive the touch event?
this questions are answered by these two methods:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
Ok, now we can continue.
After the tap, UIApplicationMain starts the hit testing process. The hit test starts from the main UIWindow (and is executed even on the status bar window and the alert view window, for example), and goes through all subviews.
This process is executed 3 times:
two times starting from UIWindow
one times starting from _UIApplicationHandleEvent
If you tap on the Navigation Bar, you will see that hitTest on UIWindow will return the UINavigationBar (all three times)
If you tap on the area below the Navigation Bar however, you will se something strange:
the first two hitTest will return your UISegmentedControl
the last hitTest will return UINavigationBar
why this?
If you swizzle and subclass UIView, overriding hitTest, you will see that the first two times the tapped point is correct. The third time, something changes the point doing something like point - 15 (or a similar number)
After a lot of searching, I have found where this is happening:
UIWindow has a (private) method called
-(CGPoint)warpPoint:(CGPoint)point;
debugging it, I saw that this method changes the tapped point if it is immediately below the status bar.
Debugging more, I saw that the stack calls that make this possible, are only 3:
[UINavigationBar, _isChargeEnabled]
[UINavigationBar, isEnabled]
[UINavigationBar, _isAlphaHittableAndHasAlphaHittableAncestors]
So, at the end, this warpPoint method checks if the UINavigationBar is enabled and hittable, if yes it "warps" the point. The point is warped of a number of pixel between 0 and 15, and this "warp" increases when you get closer to the Navigation Bar.
Now that you know what happens behind the scenes, you have to know how to avoid it (if you want).
You can't simply override warpPoint: if the application must go on the AppStore: it's a private method and your app will be rejected.
You have to find another system (like as suggested, overriding sendEvent, but I'm not sure if it will work)
Because this question is interesting, I will think about a legal solution tomorrow and update this answer (one good starting point can be subclassing UINavigationBar, overriding hitTest and pointInside, returning nil/false if, given the same event over multiple calls, the point changes. But I must test if it works tomorrow)
EDIT
Ok, I've tried many solutions but it's not simple to find a legal and stable one.
I've described the actual behavior of the system, that could vary on different versions (hitTest called more or less than 3 times, the warpPoint warping the point of about 15px that can change ecc ecc).
The most stable is obviously the illegal override of warpPoint: in a UIWindow subclass:
-(CGPoint)warpPoint:(CGPoint)point;
{
return point;
}
however, I've found that a method like this (in UIWindow subclass) it's stable enough and does the trick:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// this method is not safe if you tap the screen two times at the same x position and y position different for 16px, because it moves the point
if (self.lastPoint.x == point.x)
{
// the points are on the same vertical line
if ((0 < (self.lastPoint.y - point.y)) && ((self.lastPoint.y - point.y) < 16) )
{
// there is a differenc of ~15px in the y position?
// if so, the point has been changed
point.y = self.lastPoint.y;
}
}
self.lastPoint = point;
return [super hitTest:point withEvent:event];
}
This method records the last point tapped, and if the subsequent tap is at the same x, and an y different for max 16px, then uses the previous point.
I've tested a lot and it seems stable.
If you want, you can add more controls to enable this behavior only in particular controllers, or only on a defined portion of the window, ecc ecc.
If I find another solution, I'll update the post
I believe the problem is because the buttons in the UINavigationBar have a larger than normal touch area. See this SO post. You can also find plenty of discussion on this with a 'UINavigationBar touch area' Google search.
As a possible solution, you could put the segmented control IN the navigation bar, but you would know better than I if that fits your use cases or not.
I've come up with an alternate solution that to me seems safer than LombaX's. It uses the fact that both events come in with the same timestamp to reject the subsequent event.
#interface RFNavigationBar ()
#property (nonatomic, assign) NSTimeInterval lastOutOfBoundsEventTimestamp;
#end
#implementation RFNavigationBar
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// [rfillion 2014-03-28]
// UIApplication/UIWindow/UINavigationBar conspire against us. There's a band under the UINavigationBar for which the bar will return
// subviews instead of nil (to make those tap targets larger, one would assume). We don't want that. To do this, it seems to end up
// calling -hitTest twice. Once with a value out of bounds which is easy to check for. But then it calls it again with an altered point
// value that is actually within bounds. The UIEvent it passes to both seem to be the same. However, we can't just compare UIEvent pointers
// because it looks like these get reused and you end up rejecting valid touches if you just keep around the last bad touch UIEvent. So
// instead we keep around the timestamp of the last bad event, and try to avoid processing any events whose timestamp isn't larger.
if (point.y > self.bounds.size.height)
{
self.lastOutOfBoundsEventTimestamp = event.timestamp;
return nil;
}
if (event.timestamp <= self.lastOutOfBoundsEventTimestamp + 0.001)
{
return nil;
}
return [super hitTest:point withEvent:event];
}
#end
You might want to check which view is recording the touches. Try this method-
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
[touch locationInView:self.view];
if([touch.view isKindOfClass:[UISegmentedControl class]])
{
NSLog(#"This is UISegment");
}
else if([touch.view isKindOfClass:[UITabBar class]])
{
NSLog(#"This is UITabBar");
} else if(...other views...) {
...
}
}
Once you figure that out you maybe able to narrow down your problem.
It looks as if you're using a category extension to set width/height on views, as well as center them in their parent. Perhaps there is a hidden issue here - can you refactor to do your layout w/out this category?
I copied your code into a clean project and ran it in a UITableViewController's viewDidLoad method - it works fine and I have no dead spots like you report. I had to change your code slightly since I don't have the same category extension that you're using.
Also, if you're running this code in viewDidLoad, you should verify that your view has a defined size (you access your view.width). If you're creating your UITableViewController programmatically (vs from a nib/storyboard) then the frame may be CGRectZero. Mine was loaded from a nib so the frame was preset.
I'd also try temporarily removing your border view to see if it's the culprit.
I recommend that you avoid having touch-sensitive UI in such close proximity to the nav bar or toolbar. These areas are typically known as "slop factors" making it easier for users to perform touch events on buttons without the difficulty of performing precision touches. This is also the case for UIButtons for example.
But if you want to capture the touch event before the navigation bar or toolbar receives it, you can subclass UIWindow and override: -(void)sendEvent:(UIEvent *)event;
An easy way to debug this is to try using DCIntrospect in your project. It's a very easy to use/implement library that makes finding out what views are where when in the simulator a breeze.
Install the library and configure it
Run the application in the simulator and navigate to the screen with the issue
Press spacebar on the keyboard (the computer keyboard, not the simulator's
keyboard)
Click on the 25% area and see what gets highlighted.
If what's highlighted isn't the segmented view controller, that view could be what's covering up the touch event.
Create a protocol for UINavigationBar: (add new file and paste below code)
/******** file: UINavigationBar+BelowSpace.h*******/
"UINavigationBar+BelowSpace.h"
#import <Foundation/Foundation.h>
#interface UINavigationBar (BelowSpace)
#end
/*******- file: UINavigationBar+BelowSpace.m*******/
#import "UINavigationBar+BelowSpace.h"
#implementation UINavigationBar (BelowSpace)
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
int errorMargin = 5;// space left to decrease the click event area
CGRect smallerFrame = CGRectMake(0 , 0 - errorMargin, self.frame.size.width, self.frame.size.height);
BOOL isTouchAllowed = (CGRectContainsPoint(smallerFrame, point) == 1);
if (isTouchAllowed) {
self.userInteractionEnabled = YES;
} else {
self.userInteractionEnabled = NO;
}
return [super hitTest:point withEvent:event];
}
#end
Hope this help ^ ^
Try this
self.navigationController!.navigationBar.userInteractionEnabled = false;

Resources