Increase touch gesture area for UILabel [duplicate] - ios

This question already has answers here:
UIButton: Making the hit area larger than the default hit area
(36 answers)
Closed 15 days ago.
I have a UILabel that I have attached a touch, pinch, and rotate gesture to. The problem I am having is that there are only a few characters in it which can make it hard to rotate/pinch/touch. Is there a way that I can add a margin to the UILabel or add some area around the UILabel that will fire the rotate/pinch/touch action to make it easier to manipulate?
Here is an example of the method:
- (IBAction)handlePinch:(UIPinchGestureRecognizer *)recognizer {
recognizer.view.transform = CGAffineTransformScale(recognizer.view.transform, recognizer.scale, recognizer.scale);
currentAction = TEXT_STRETCHING;
recognizer.scale = 1;
}

There are a number of solutions for your issue:
Add gesture to another, bigger view.
If your label text is
centered you can make the label itself wider and the appearance will
remain as it was.
You can subclass UILabel and override hitTest
method. This way you can increase the area of gesture recognition
for your custom view:
CustomLabel.m:
#implementation CustomLabel: UILabel
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGRect bigRect = CGRectInset(self.bounds, -10, -10);
return CGRectContainsPoint(bigRect, point);
}
#end
You can obviously customize the inset values for dx and dy to suit your needs.
More details about this method here:
https://developer.apple.com/reference/uikit/uiview/1622533-pointinside?language=objc
Storyboard/xib
Set CustomLabel class instead of UILabel
ViewController.m:
Leave everything as is. Your handlePinch: method is just fine.
P.S. Perhaps it would be better to stick with UIButton instead of UILabel, but this technique may still be helpful.

Related

drawRect not responding to setNeedsDisplay (mcve)

I am aware that this question has been asked many times, but I have found a partial resolution.
I have a custom class GraphView in which I have several sliders which change the graph parameters and instigate a redraw using [self setNeedsDisplay]. The only way I can get the setNeedsDisplay to work is to have the view of type GraphView just under the View Controller and the slider just under (and inside) the GraphView (in the storyboard hierarchy). This is problematic since the slider must be inside the graph.
Here is an mcve #interface:
#import <UIKit/UIKit.h>
#interface GraphView : UIView
#property float red;
#property __weak IBOutlet UITextField *redout;
#property UIBezierPath *aPath;
#property CGPoint aPoint;
- (void)drawRect:(CGRect)rect;
- (id) initWithFrame:(CGRect)frameRect;
- (IBAction)red_rabi:(id)sender;
Here is the mcve #implementation:
#import "GraphView.h"
#implementation GraphView
- (id) initWithFrame:(CGRect)frameRect
{
if ((self = [super initWithFrame:frameRect]) != nil)
{
_red=1.0;
}
return self;
}
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
int i;
NSString *redstr;
float width, height;
width = rect.size.width;
height = rect.size.height;
_aPath =[UIBezierPath bezierPathWithRect:rect] ;
[_aPath setLineWidth:1.0];
_aPoint.x=0.0;
_aPoint.y=0.0;
[_aPath moveToPoint:_aPoint];
redstr = [NSString localizedStringWithFormat:#"%6.2f", _red];
for (i=1;i<400;i++)
{
_aPoint.x=i*width/400.0;
_aPoint.y=height-height*(sin(i*_red/30.)+1.0)/2.0;
[_aPath addLineToPoint:_aPoint];
}
[_aPath stroke];
}
- (IBAction)red_rabi:(id)sender
{
NSString *redstr;
UISlider *slider = (UISlider *)sender;
_red= slider.value;
redstr = [NSString localizedStringWithFormat:#"%6.2f", _red];
_redout.text = redstr;
[self setNeedsDisplay];
}
#end
If you place a generic View just underneath the View Controller (which I didn't touch), change the generic View's class to GraphView, and place a slider and TextField inside the GraphView (connecting them to the outlet and action), this app will generate a few cycles of a sine wave with the frequency controlled by the slider and its value displayed in the TextField.
If you want the slider and TextField in another view, one must use an enveloping view for all three items (GraphView, slider, text) and one cannot connect the slider and TextField to the GraphView using Ctrl_drag to the GraphView.h file. To remedy this, I placed a generic Object at the highest level and renamed it GraphView - I could then connect the slider and TextField. Although the Textfield reads correctly, the slider doesn't update the GraphView.
By the way, essentially the same code with the GraphView and slider in separate views works perfectly in OS X.
Sorry for the length of this query and thanks!
Problem solved! Due to an interesting SO post from three years ago (about connecting to subviews of UIView), I discovered that one merely drags (not Ctrl_drag!) from the action or outlet circle (in the .h file) to the control and that's it. Works perfectly even when the controls are in a different view from the subclassed UIView. Works equally well with outlets as with actions though you always drag away from the circle.
Thanks to all for their help.

iOS - Resize multiple views with touch-drag separators

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.

UIPageViewController with UISlider inside controller - increase hit area of slider

I have multiple controllers in my PageViewController and in one controller I have a few sliders. Now there is a problem that user must touch exactly slider circle (I am not sure about right expression, thumb? - that moving part) and I would like to increase area in which reacts slider and not the whole PageViewController. I tried these solutions but it doesn't help:
thumbRectForBounds:
- (CGRect)thumbRectForBounds:(CGRect)bounds trackRect:(CGRect)rect value:(float)value
{
return CGRectInset ([super thumbRectForBounds:bounds trackRect:rect value:value], 15, 15);
}
Increase hitTest area:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (CGRectContainsPoint(CGRectInset(self.frame, 200, 200), point) || CGRectContainsPoint(CGRectInset(self.frame, 200, 200), point)) {
return self;
}
return [super hitTest:point withEvent:event];
}
I have these methods in my custom slider class because I would like to reuse this. Last thing what I found and not tried yet is create some object layer over slider which "takes" gesture and disable PageViewController but I am not sure how to do it and I am not sure if it's good/best solution.
I am not a big fan of the UISlider component because as you noticed, it is not trivial to increase the hit area of the actual slider. I would urge you to replicate the UISlider instead using a pan gesture for a much better user experience:
i. create a slider background with a seperate UIImageView with a slider image.
ii. create the PanGesture:
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(handlePan:);
[imageView addGestureRecognizer:pan];
iii. implement handlePan Method:
- (IBAction)handlePan:(UIPanGestureRecognizer *)recognizer {
//pan (slide) begins
CGPoint translation = [recognizer locationInView:self.view];
translation.y = self.slideImage.center.y;
self.slideImage.center = translation;
if(recognizer.state == UIGestureRecognizerStateEnded) {
LTDebugLog(#"\n\n PAN, with spot: %f\n\n", self.slideImage.center.x);
//do something after user is done sliding
}
}
The big benefit of this method is that you will have a much better user experience as you can make the responsive UIImageView as big as you want.
Alternatively, you could subclass a UISlider and increase the hit space there, although in my experience this gives mixed results.
Hope this helps
In your CustomSlider class override thumbRectForBounds method:
Simply return rect value as you required:
- (CGRect)thumbRectForBounds:(CGRect)bounds trackRect:(CGRect)rect value:(float)value
{
return CGRectMake (bounds.origin.x, bounds.origin.y, yourWidthValue, yourHeightValue );
}
Change yourWidthValue and yourHeightValue as per your requirement. And then while using
Create object like below:
CustomSlider *slider = [[CustomSlider alloc] initWithFrame:CGRectMake(0, 0, 300, 20)];
[slider thumbRectForBounds: slider.bounds trackRect:slider.frame value:15.f]; // change values as per your requirements.
Hope this helps.
Create a custom thumb image which has a large empty margin and set that on your slider, like this:
[theSlider setThumbImage:[UIImage imageNamed:#"slider_thumb_with_margins"] forState:UIControlStateNormal];
To make the image, get a copy of the system thumb image using any one of a number of UIKit artwork extractors (just search the web for one). Open the thumb image in Photoshop and increase the canvas size by twice the margin you want to add. Make sure you change the canvas size and not the image size, as the image size will stretch the image to fill the new size. This will put empty space around the thumb which will be part of the hit-test area but since it is all transparent it won't change the look of the slider.

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

iOS: Implement a rotating wheel with custom views being horizontally stationary [duplicate]

I am looking for a little guidance to start figuring out an animation that tracks finger movement and moves a collection of UIButtons along the outer path of a circle
I am picturing it will kind of have a revolver feel to it. Like each one locks into place at the bottom
or like the swiping through one of the slide inserts of these
thanks in advance
(Sample code on GitHub)
It's not really that difficult, there's just a lot of trigonometry involved.
Now, what I'm going to describe now, is not an animation, since you requested for it to track the position of your finger in the title. An animation would involve its own timing function, but since you're using the touch gesture, we can use the inherent timing that this event has and just rotate the view accordingly. (TL;DR: The user keeps the time of the movement, not an implicit timer).
Keeping track of the finger
First of all, let's define a convenient class that keeps track of the angle, I'm gonna call it DialView. It's really just a subclass of UIView, that has the following property:
DialView.h
#interface DialView : UIView
#property (nonatomic,assign) CGFloat angle;
#end
DialView.m
- (void)setAngle:(CGFloat)angle
{
_angle = angle;
self.transform = CGAffineTransformMakeRotation(angle);
}
The UIButtons can be contained within this view (I'm not sure if you want the buttons to be responsible for the rotation? I'm gonna use a UIPanGestureRecognizer, since it's the most convenient way).
Let's build the view controller that will handle a pan gesture inside our DialView, let's also keep a reference to DialView.
MyViewController.h
#class DialView;
#interface ViewController : UIViewController
// The previously defined dial view
#property (nonatomic,weak) IBOutlet DialView *dial;
// UIPanGesture selector method
- (IBAction)didReceiveSpinPanGesture:(UIPanGestureRecognizer*)gesture;
#end
It's up to you on how you hook up the pan gesture, personally, I made it on the nib file. Now, the main body of this function:
MyViewController.m
- (IBAction)didReceiveSpinPanGesture:(UIPanGestureRecognizer*)gesture
{
// This struct encapsulates the state of the gesture
struct state
{
CGPoint touch; // Current touch position
CGFloat angle; // Angle of the view
CGFloat touchAngle; // Angle between the finger and the view
CGPoint center; // Center of the view
};
// Static variable to record the beginning state
// (alternatively, use a #property or an _ivar)
static struct state begin;
CGPoint touch = [gesture locationInView:nil];
if (gesture.state == UIGestureRecognizerStateBegan)
{
begin.touch = touch;
begin.angle = self.dial.angle;
begin.center = self.dial.center;
begin.touchAngle = CGPointAngle(begin.touch, begin.center);
}
else if (gesture.state == UIGestureRecognizerStateChanged)
{
struct state now;
now.touch = touch;
now.center = begin.center;
// Get the current angle between the finger and the center
now.touchAngle = CGPointAngle(now.touch, now.center);
// The angle of the view shall be the original angle of the view
// plus or minus the difference between the two touch angles
now.angle = begin.angle - (begin.touchAngle - now.touchAngle);
self.dial.angle = now.angle;
}
else if (gesture.state == UIGestureRecognizerStateEnded)
{
// (To be Continued ...)
}
}
CGPointAngle is a method invented by me, it's just a nice wrapper for atan2 (and I'm throwing CGPointDistance in if you call NOW!):
CGFloat CGPointAngle(CGPoint a, CGPoint b)
{
return atan2(a.y - b.y, a.x - b.x);
}
CGFloat CGPointDistance(CGPoint a, CGPoint b)
{
return sqrt(pow((a.x - b.x), 2) + pow((a.y - b.y), 2));
}
The key here, is that there's two angles to keep track:
The angle of the view itself
The angle formed between your finger and the center of the view.
In this case, we want to have the view's angle be originalAngle + deltaFinger, and that's what the above code does, I just encapsulate all the state in a struct.
Checking the radius
If that you want to keep track on "the border of the view", you should use the CGPointDistance method and check if the distance between the begin.center and the now.finger is an specific value.
That's homework for ya!
Snapping back
Ok, now, this part is an actual animation, since the user no longer has control of it.
The snapping can be achieved by having a set defined of angles, and when the finger releases the finger (Gesture ended), snap back to them, like this:
else if (gesture.state == UIGestureRecognizerStateEnded)
{
// Number of "buttons"
NSInteger buttons = 8;
// Angle between buttons
CGFloat angleDistance = M_PI*2 / buttons;
// Get the closest angle
CGFloat closest = round(self.dial.angle / angleDistance) * angleDistance;
[UIView animateWithDuration:0.15 animations:^{
self.dial.angle = closest;
}];
}
The buttons variable, is just a stand-in for the number of buttons that are in the view. The equation is super simple actually, it just abuses the rounding function to approximate to the closes angle.

Resources