How to enable "tap and slide" in a UISlider? - ios

What I want to get is a UISlider which lets the user not only slide when he starts on its thumbRect, but also when he taps elsewhere. When the user taps on the slider but outside of the thumbRect, the slider should jump to that value and then still keeping up to the user's sliding gesture.
What I have tried so far was implementing a subclass of UIGestureRecognizer like in this suggestion. It starts right then when a touch down somewhere outside the thumbRect occurs. The problem is that the slider sets its value but then further sliding gestures are ignored because the touch down recognizer has stolen the touch.
How can I implement a slider where you can tap anywhere but still slide right away?
Edit: ali59a was so kind to add an example of what I've done now. This requires to lift the finger again, after that I can touch and drag to slide (a tap is not what I want, I need a 'touch and slide' right away).

I'm not sure if you are still looking for an answer for this, but I was just looking at this myself today; and I managed to get it to work for me.
The key to it, is using a UILongPressGestureRecognizer instead of just a UITapGestureRecognizer, we can then set the minimumPressDuration of the recognizer to 0; making it act as a tap recognizer, except you can now actually check its state.
Putting what ali59a suggested will work for you, just by replacing the UITapGestureRecognizer with a UILongPressGestureRecognizer. However, I found that this didn't seem to quite put the thumbRect directly under my thumb. It appeared a bit off to me.
I created my own UISlider subclass for my project, and here is how I implemented the "tap and slide feature" for me.
In my init method:
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc]initWithTarget:self action:#selector(tapAndSlide:)];
longPress.minimumPressDuration = 0;
[self addGestureRecognizer:longPress];
Then my tapAndSlide: method:
- (void)tapAndSlide:(UILongPressGestureRecognizer*)gesture
{
CGPoint pt = [gesture locationInView: self];
CGFloat thumbWidth = [self thumbRect].size.width;
CGFloat value;
if(pt.x <= [self thumbRect].size.width/2.0)
value = self.minimumValue;
else if(pt.x >= self.bounds.size.width - thumbWidth/2.0)
value = self.maximumValue;
else {
CGFloat percentage = (pt.x - thumbWidth/2.0)/(self.bounds.size.width - thumbWidth);
CGFloat delta = percentage * (self.maximumValue - self.minimumValue);
value = self.minimumValue + delta;
}
if(gesture.state == UIGestureRecognizerStateBegan){
[UIView animateWithDuration:0.35 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
[self setValue:value animated:YES];
[super sendActionsForControlEvents:UIControlEventValueChanged];
} completion:nil];
}
else [self setValue:value];
if(gesture.state == UIGestureRecognizerStateChanged)
[super sendActionsForControlEvents:UIControlEventValueChanged];
}
Where I also used a method to return the frame of my custom thumbRect:
- (CGRect)thumbRect {
CGRect trackRect = [self trackRectForBounds:self.bounds];
return [self thumbRectForBounds:self.bounds trackRect:trackRect value:self.value];
}
I also have my slider animate to the position where the user first taps, over 0.35 seconds. Which I reckon looks pretty sweet, so I included that in that code.
If you don't want that, simply try this:
- (void)tapAndSlide:(UILongPressGestureRecognizer*)gesture
{
CGPoint pt = [gesture locationInView: self];
CGFloat thumbWidth = [self thumbRect].size.width;
CGFloat value;
if(pt.x <= [self thumbRect].size.width/2.0)
value = self.minimumValue;
else if(pt.x >= self.bounds.size.width - thumbWidth/2.0)
value = self.maximumValue;
else {
CGFloat percentage = (pt.x - thumbWidth/2.0)/(self.bounds.size.width - thumbWidth);
CGFloat delta = percentage * (self.maximumValue - self.minimumValue);
value = self.minimumValue + delta;
}
[self setValue:value];
if(gesture.state == UIGestureRecognizerStateChanged)
[super sendActionsForControlEvents:UIControlEventValueChanged];
}
I hope that makes sense, and helps you.

I converted the answer provided by DWilliames to Swift
Inside your viewDidAppear()
let longPress = UILongPressGestureRecognizer(target: self.slider, action: Selector("tapAndSlide:"))
longPress.minimumPressDuration = 0
self.addGestureRecognizer(longPress)
Class file
class TapUISlider: UISlider
{
func tapAndSlide(gesture: UILongPressGestureRecognizer)
{
let pt = gesture.locationInView(self)
let thumbWidth = self.thumbRect().size.width
var value: Float = 0
if (pt.x <= self.thumbRect().size.width / 2)
{
value = self.minimumValue
}
else if (pt.x >= self.bounds.size.width - thumbWidth / 2)
{
value = self.maximumValue
}
else
{
let percentage = Float((pt.x - thumbWidth / 2) / (self.bounds.size.width - thumbWidth))
let delta = percentage * (self.maximumValue - self.minimumValue)
value = self.minimumValue + delta
}
if (gesture.state == UIGestureRecognizerState.Began)
{
UIView.animateWithDuration(0.35, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut,
animations:
{
self.setValue(value, animated: true)
super.sendActionsForControlEvents(UIControlEvents.ValueChanged)
},
completion: nil)
}
else
{
self.setValue(value, animated: false)
}
}
func thumbRect() -> CGRect
{
return self.thumbRectForBounds(self.bounds, trackRect: self.bounds, value: self.value)
}
}

You should add a tap gesture on your UISlider.
Exemple :
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(sliderTapped:)];
[_slider addGestureRecognizer:tapGestureRecognizer];
In sliderTapped you can get the location and update the value of the slider :
- (void)sliderTapped:(UIGestureRecognizer *)gestureRecognizer {
CGPoint pointTaped = [gestureRecognizer locationInView:gestureRecognizer.view];
CGPoint positionOfSlider = _slider.frame.origin;
float widthOfSlider = _slider.frame.size.width;
float newValue = ((pointTaped.x - positionOfSlider.x) * _slider.maximumValue) / widthOfSlider;
[_slider setValue:newValue];
}
I create an example here : https://github.com/ali59a/tap-and-slide-in-a-UISlider

Here's my modification to the above:
class TapUISlider: UISlider {
func tapAndSlide(gesture: UILongPressGestureRecognizer) {
let pt = gesture.locationInView(self)
let thumbWidth = self.thumbRect().size.width
var value: Float = 0
if (pt.x <= self.thumbRect().size.width / 2) {
value = self.minimumValue
} else if (pt.x >= self.bounds.size.width - thumbWidth / 2) {
value = self.maximumValue
} else {
let percentage = Float((pt.x - thumbWidth / 2) / (self.bounds.size.width - thumbWidth))
let delta = percentage * (self.maximumValue - self.minimumValue)
value = self.minimumValue + delta
}
if (gesture.state == UIGestureRecognizerState.Began) {
UIView.animateWithDuration(0.35, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut,
animations: {
self.setValue(value, animated: true)
super.sendActionsForControlEvents(UIControlEvents.ValueChanged)
}, completion: nil)
} else {
self.setValue(value, animated: false)
super.sendActionsForControlEvents(UIControlEvents.ValueChanged)
}
}
func thumbRect() -> CGRect {
return self.thumbRectForBounds(self.bounds, trackRect: self.bounds, value: self.value)
}
}

Adding swift version of Ali AB.'s answer,
#IBAction func sliderTappedAction(sender: UITapGestureRecognizer)
{
if let slider = sender.view as? UISlider {
if slider.highlighted { return }
let point = sender.locationInView(slider)
let percentage = Float(point.x / CGRectGetWidth(slider.bounds))
let delta = percentage * (slider.maximumValue - slider.minimumValue)
let value = slider.minimumValue + delta
slider.setValue(value, animated: true)
}
}

I didn't check David Williames answer, but I'll post my solution in case someone is looking for another way to do it.
Swift 4
First create a custom UISlider so that it will detect touches on the bar as well :
class CustomSlider: UISlider {
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
return true
}
}
(don't forget to set your slider to be this CustomSlider, on storyboard)
The on viewDidLoad of the view controller that is displaying the slider:
self.slider.addTarget(self, action: #selector(sliderTap), for: .touchDown)
(this is only used to pause the player when moving the slider)
Then, on your UISlider action:
#IBAction func moveSlider(_ sender: CustomSlider, forEvent event: UIEvent) {
if let touchEvent = event.allTouches?.first {
switch touchEvent.phase {
case .ended, .cancelled, .stationary:
//here, start playing if needed
startPlaying()
default:
break
}
}
}
And on your "sliderTap" selector method:
#objc func sliderTap() {
//pause the player, if you want
audioPlayer?.pause()
}
Suggestion: set the player "currentTime" before starting to play:
private func startPlaying() {
audioPlayer?.currentTime = Double(slider.value)
audioPlayer?.play()
}

Updated tsji10dra's answer to Swift 4:
#IBAction func sliderTappedAction(sender: UITapGestureRecognizer) {
if let slider = sender.view as? UISlider {
if slider.isHighlighted { return }
let point = sender.location(in: slider)
let percentage = Float(point.x / slider.bounds.size.width)
let delta = percentage * (slider.maximumValue - slider.minimumValue)
let value = slider.minimumValue + delta
slider.setValue(value, animated: true)
// also remember to call valueChanged if there's any
// custom behaviour going on there and pass the slider
// variable as the parameter, as indicated below
self.sliderValueChanged(slider)
}
}

My solution is quite simple:
class CustomSlider: UISlider {
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let newValue = <calculated_value>
self.setValue(newValue, animated: false)
super.sendActions(for: UIControlEvents.valueChanged)
return true
}}

This works for me in iOS 13.6 & 14.0
No need to add gesture only override beginTracking function like this :
#objc
private func sliderTapped(touch: UITouch) {
let point = touch.location(in: self)
let percentage = Float(point.x / self.bounds.width)
let delta = percentage * (self.maximumValue - self.minimumValue)
let newValue = self.minimumValue + delta
if newValue != self.value {
value = newValue
sendActions(for: .valueChanged)
}
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
sliderTapped(touch: touch)
return true
}

I completed #DWilliames solution for a UISlider subclass containing minimum and maximumValueImages.
Additionally I implemented a functionality for user touches in the areas outside the trackArea (means either the area around the minimum or the maximumValueImage). Touching these areas moves the slider/changes the value in intervals.
- (void) tapAndSlide: (UILongPressGestureRecognizer*) gesture {
CGPoint touchPoint = [gesture locationInView: self];
CGRect trackRect = [self trackRectForBounds: self.bounds];
CGFloat thumbWidth = [self thumbRectForBounds: self.bounds trackRect: trackRect value: self.value].size.width;
CGRect trackArea = CGRectMake(trackRect.origin.x, 0, trackRect.size.width, self.bounds.size.height);
CGFloat value;
if (CGRectContainsPoint(trackArea, touchPoint)) {
if (touchPoint.x <= trackArea.origin.x + thumbWidth/2.0) {
value = self.minimumValue;
}
else if (touchPoint.x >= trackArea.origin.x + trackArea.size.width - thumbWidth/2.0) {
value = self.maximumValue;
}
else {
CGFloat percentage = (touchPoint.x - trackArea.origin.x - thumbWidth/2.0)/(trackArea.size.width - thumbWidth);
CGFloat delta = percentage*(self.maximumValue - self.minimumValue);
value = self.minimumValue + delta;
}
if (value != self.value) {
if (gesture.state == UIGestureRecognizerStateBegan) {
[UIView animateWithDuration: 0.2 delay: 0 options: UIViewAnimationOptionCurveEaseInOut animations: ^{
[self setValue: value animated: YES];
} completion: ^(BOOL finished) {
[self sendActionsForControlEvents: UIControlEventValueChanged];
}];
}
else {
[self setValue: value animated: YES];
[self sendActionsForControlEvents: UIControlEventValueChanged];
}
}
}
else {
if (gesture.state == UIGestureRecognizerStateBegan) {
if (touchPoint.x <= trackArea.origin.x) {
if (self.value == self.minimumValue) return;
value = self.value - 1.5;
}
else {
if (self.value == self.maximumValue) return;
value = self.value + 1.5;
}
CGFloat duration = 0.1;
[UIView animateWithDuration: duration delay: 0 options: UIViewAnimationOptionCurveEaseInOut animations: ^{
[self setValue: value animated: YES];
} completion: ^(BOOL finished) {
[self sendActionsForControlEvents: UIControlEventValueChanged];
}];
}
}
}

To expand on the answer of Khang Azun- for swift 5 put the following in a UISlider custom class:
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let percent = Float(touch.location(in: self).x / bounds.size.width)
let delta = percent * (maximumValue - minimumValue)
let newValue = minimumValue + delta
self.setValue(newValue, animated: false)
super.sendActions(for: UIControl.Event.valueChanged)
return true
}

At the risk of being chastised by the iOS pure community...
Here is a solution for Xamarin iOS C# converted from David Williames Answer.
Sub class UISlider:
[Register(nameof(UISliderCustom))]
[DesignTimeVisible(true)]
public class UISliderCustom : UISlider
{
public UISliderCustom(IntPtr handle) : base(handle) { }
public UISliderCustom()
{
// Called when created from code.
Initialize();
}
public override void AwakeFromNib()
{
// Called when loaded from xib or storyboard.
Initialize();
}
void Initialize()
{
// Common initialization code here.
var longPress = new UILongPressGestureRecognizer(tapAndSlide);
longPress.MinimumPressDuration = 0;
//longPress.CancelsTouchesInView = false;
this.AddGestureRecognizer(longPress);
this.UserInteractionEnabled = true;
}
private void tapAndSlide(UILongPressGestureRecognizer gesture)
{
System.Diagnostics.Debug.WriteLine($"{nameof(UISliderCustom)} RecognizerState {gesture.State}");
// need to propagate events down the chain
// I imagine iOS does something similar
// for whatever recogniser on the thumb control
// It's not enough to set CancelsTouchesInView because
// if clicking on the track away from the thumb control
// the thumb gesture recogniser won't pick it up anyway
switch (gesture.State)
{
case UIGestureRecognizerState.Cancelled:
this.SendActionForControlEvents(UIControlEvent.TouchCancel);
break;
case UIGestureRecognizerState.Began:
this.SendActionForControlEvents(UIControlEvent.TouchDown);
break;
case UIGestureRecognizerState.Changed:
this.SendActionForControlEvents(UIControlEvent.ValueChanged);
break;
case UIGestureRecognizerState.Ended:
this.SendActionForControlEvents(UIControlEvent.TouchUpInside);
break;
case UIGestureRecognizerState.Failed:
//?
break;
case UIGestureRecognizerState.Possible:
//?
break;
}
var pt = gesture.LocationInView(this);
var thumbWidth = CurrentThumbImage.Size.Width;
var value = 0f;
if (pt.X <= thumbWidth / 2)
{
value = this.MinValue;
}
else if (pt.X >= this.Bounds.Size.Width - thumbWidth / 2)
{
value = this.MaxValue;
}
else
{
var percentage = ((pt.X - thumbWidth / 2) / (this.Bounds.Size.Width - thumbWidth));
var delta = percentage * (this.MaxValue - this.MinValue);
value = this.MinValue + (float)delta;
}
if (gesture.State == UIGestureRecognizerState.Began)
{
UIView.Animate(0.35, 0, UIViewAnimationOptions.CurveEaseInOut,
() =>
{
this.SetValue(value, true);
},
null);
}
else
{
this.SetValue(value, animated: false);
}
}
}

From Apple,
https://developer.apple.com/forums/thread/108317
Now this works fine on iOS 10 and iOS 11. You can slide as usual and thanks to the above code you can tap on slider and it slides automatically. However in iOS 12 this doesn't work. You have to force touch on it for tap to work

Here is my solution that works :
import UIKit
class CustomSlider: UISlider {
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
private func setupView() {
addTapGesture()
}
private func addTapGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
addGestureRecognizer(tap)
}
#objc private func handleTap(_ sender: UITapGestureRecognizer) {
let location = sender.location(in: self)
let percent = minimumValue + Float(location.x / bounds.width) * maximumValue
setValue(percent, animated: true)
sendActions(for: .valueChanged)
}
}

Related

Running swift condition just once on translation

I created a UIView and a UIImageView which is inside the UIView as a subview, then I added a pan gesture to the UIImageView to slide within the UIView, the image slides now but the problem I have now is when the slider gets to the end of the view if movex > xMax, I want to print this just once print("SWIPPERD movex"). The current code I have there continues to print print("SWIPPERD movex") as long as the user does not remove his/her hand from the UIImageView which is used to slide
private func swipeFunc() {
let swipeGesture = UIPanGestureRecognizer(target: self, action: #selector(acknowledgeSwiped(sender:)))
sliderImage.addGestureRecognizer(swipeGesture)
swipeGesture.delegate = self as? UIGestureRecognizerDelegate
}
#objc func acknowledgeSwiped(sender: UIPanGestureRecognizer) {
if let sliderView = sender.view {
let translation = sender.translation(in: self.baseView) //self.sliderView
switch sender.state {
case .began:
startingFrame = sliderImage.frame
viewCenter = baseView.center
fallthrough
case .changed:
if let startFrame = startingFrame {
var movex = translation.x
if movex < -startFrame.origin.x {
movex = -startFrame.origin.x
print("SWIPPERD minmax")
}
let xMax = self.baseView.frame.width - startFrame.origin.x - startFrame.width - 15 //self.sliderView
if movex > xMax {
movex = xMax
print("SWIPPERD movex")
}
var movey = translation.y
if movey < -startFrame.origin.y { movey = -startFrame.origin.y }
let yMax = self.baseView.frame.height - startFrame.origin.y - startFrame.height //self.sliderView
if movey > yMax {
movey = yMax
// print("SWIPPERD min")
}
sliderView.transform = CGAffineTransform(translationX: movex, y: movey)
}
default: // .ended and others:
UIView.animate(withDuration: 0.1, animations: {
sliderView.transform = CGAffineTransform.identity
})
}
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return sliderImage.frame.contains(point)
}
You may want to use the .ended state instead of .changed state, based on your requirements. And you've mentioned you want to get the right direction only. You could try below to determine if the swipe came from right to left, or vice-versa, change as you wish:
let velocity = sender.velocity(in: sender.view)
let rightToLeftSwipe = velocity.x < 0

UIImageView disappears after rotate and move to edge

I have written a custom class which offers the feature to move and rotates images.
I need to restrict the movement to the boundaries of parent view or Superview.
So, I wrote below code to restrict it.
This works fine before an image is rotated. If I try to rotate the image and then move the image to an edge, Image disappears leaving no log or traces.
Why does it disappear and how do I avoid it?
if(frame.origin.x < 1)
{
frame.origin.x = 1
}
if(frame.origin.y < 1)
{
frame.origin.y = 1
}
if(frame.maxX > superview!.frame.width)
{
frame.origin.x = superview!.frame.width - frame.width - 1
}
if(frame.maxY > superview!.frame.height)
{
frame.origin.y = superview!.frame.height - frame.height - 1
}
If I remove the above code, nothing disappears but image moves out of boundaries. So I feel something wrong in only above lines.
So help me to correctly implement this feature after rotation.
Full Movable Image Class code :
class movableImageView: UIImageView
{
var CenCooVar = CGPoint()
override init(image: UIImage!)
{
super.init(image: image)
self.userInteractionEnabled = true
let moveImage = UIPanGestureRecognizer(target: self, action: #selector(moveImageFnc(_:)))
let rotateImage = UIRotationGestureRecognizer(target: self, action: #selector(rotateImageFnc(_:)))
self.gestureRecognizers = [moveImage,rotateImage]
}
func moveImageFnc(moveImage: UIPanGestureRecognizer)
{
if moveImage.state == UIGestureRecognizerState.Began
{
CenCooVar = self.center
}
if moveImage.state == UIGestureRecognizerState.Changed
{
let moveCooVar = moveImage.translationInView(self.superview!)
self.center = CGPoint(x: CenCooVar.x + moveCooVar.x, y: CenCooVar.y + moveCooVar.y)
if(frame.origin.x < 1)
{
frame.origin.x = 1
}
if(frame.origin.y < 1)
{
frame.origin.y = 1
}
if(frame.maxX > superview!.frame.width)
{
frame.origin.x = superview!.frame.width - frame.width - 1
}
if(frame.maxY > superview!.frame.height)
{
frame.origin.y = superview!.frame.height - frame.height - 1
}
}
if moveImage.state == UIGestureRecognizerState.Ended
{
CenCooVar = self.center
}
}
func rotateImageFnc(rotateImage: UIRotationGestureRecognizer)
{
if rotateImage.state == UIGestureRecognizerState.Changed
{
self.transform = CGAffineTransformRotate(self.transform, rotateImage.rotation)
rotateImage.rotation = 0
}
}
}

Animating UICollectionView contentOffset does not display non-visible cells

I'm working on some ticker-like functionality and am using a UICollectionView. It was originally a scrollView, but we figure a collectionView will make it easier to add/remove cells.
I am animating the collectionView with the following:
- (void)beginAnimation {
[UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
} completion:nil];
}
This works fine for the scroll view, and the animation is happening with the collection view. However, only the cells that are visible at the end of the animation are actually rendered. Adjusting the contentOffset is not causing cellForItemAtIndexPath to be called. How can I get the cells to render when the contentOffset changes?
EDIT:
For a bit more reference (not sure if it's much help):
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
TickerElementCell *cell = (TickerElementCell *)[collectionView dequeueReusableCellWithReuseIdentifier:#"TickerElementCell" forIndexPath:indexPath];
cell.ticker = [self.fetchedResultsController objectAtIndexPath:indexPath];
return cell;
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
// ...
[self loadTicker];
}
- (void)loadTicker {
// ...
if (self.animating) {
[self updateAnimation];
}
else {
[self beginAnimation];
}
}
- (void)beginAnimation {
if (self.animating) {
[self endAnimation];
}
if ([self.tickerElements count] && !self.animating && !self.paused) {
self.animating = YES;
self.collectionView.contentOffset = CGPointMake(1, 0);
[UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
} completion:nil];
}
}
You should simply add [self.view layoutIfNeeded]; inside the animation block, like so:
[UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
[self.view layoutIfNeeded];
} completion:nil];
You could try using a CADisplayLink to drive the animation yourself. This is not too hard to set up since you are using a Linear animation curve anyway. Here's a basic implementation that may work for you:
#property (nonatomic, strong) CADisplayLink *displayLink;
#property (nonatomic, assign) CFTimeInterval lastTimerTick;
#property (nonatomic, assign) CGFloat animationPointsPerSecond;
#property (nonatomic, assign) CGPoint finalContentOffset;
-(void)beginAnimation {
self.lastTimerTick = 0;
self.animationPointsPerSecond = 50;
self.finalContentOffset = CGPointMake(..., ...);
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:#selector(displayLinkTick:)];
[self.displayLink setFrameInterval:1];
[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
-(void)endAnimation {
[self.displayLink invalidate];
self.displayLink = nil;
}
-(void)displayLinkTick {
if (self.lastTimerTick = 0) {
self.lastTimerTick = self.displayLink.timestamp;
return;
}
CFTimeInterval currentTimestamp = self.displayLink.timestamp;
CGPoint newContentOffset = self.collectionView.contentOffset;
newContentOffset.x += self.animationPointsPerSecond * (currentTimestamp - self.lastTimerTick)
self.collectionView.contentOffset = newContentOffset;
self.lastTimerTick = currentTimestamp;
if (newContentOffset.x >= self.finalContentOffset.x)
[self endAnimation];
}
I've built upon what's already in these answers and made a generic manual animator, as everything can be distilled down to a percentage float value and a block.
class ManualAnimator {
enum AnimationCurve {
case linear, parametric, easeInOut, easeIn, easeOut
func modify(_ x: CGFloat) -> CGFloat {
switch self {
case .linear:
return x
case .parametric:
return x.parametric
case .easeInOut:
return x.quadraticEaseInOut
case .easeIn:
return x.quadraticEaseIn
case .easeOut:
return x.quadraticEaseOut
}
}
}
private var displayLink: CADisplayLink?
private var start = Date()
private var total = TimeInterval(0)
private var closure: ((CGFloat) -> Void)?
private var animationCurve: AnimationCurve = .linear
func animate(duration: TimeInterval, curve: AnimationCurve = .linear, _ animations: #escaping (CGFloat) -> Void) {
guard duration > 0 else { animations(1.0); return }
reset()
start = Date()
closure = animations
total = duration
animationCurve = curve
let d = CADisplayLink(target: self, selector: #selector(tick))
d.add(to: .current, forMode: .common)
displayLink = d
}
#objc private func tick() {
let delta = Date().timeIntervalSince(start)
var percentage = animationCurve.modify(CGFloat(delta) / CGFloat(total))
//print("%:", percentage)
if percentage < 0.0 { percentage = 0.0 }
else if percentage >= 1.0 { percentage = 1.0; reset() }
closure?(percentage)
}
private func reset() {
displayLink?.invalidate()
displayLink = nil
}
}
extension CGFloat {
fileprivate var parametric: CGFloat {
guard self > 0.0 else { return 0.0 }
guard self < 1.0 else { return 1.0 }
return ((self * self) / (2.0 * ((self * self) - self) + 1.0))
}
fileprivate var quadraticEaseInOut: CGFloat {
guard self > 0.0 else { return 0.0 }
guard self < 1.0 else { return 1.0 }
if self < 0.5 { return 2 * self * self }
return (-2 * self * self) + (4 * self) - 1
}
fileprivate var quadraticEaseOut: CGFloat {
guard self > 0.0 else { return 0.0 }
guard self < 1.0 else { return 1.0 }
return -self * (self - 2)
}
fileprivate var quadraticEaseIn: CGFloat {
guard self > 0.0 else { return 0.0 }
guard self < 1.0 else { return 1.0 }
return self * self
}
}
Implementation
let initialOffset = collectionView.contentOffset.y
let delta = collectionView.bounds.size.height
let animator = ManualAnimator()
animator.animate(duration: TimeInterval(1.0), curve: .easeInOut) { [weak self] (percentage) in
guard let `self` = self else { return }
self.collectionView.contentOffset = CGPoint(x: 0.0, y: initialOffset + (delta * percentage))
if percentage == 1.0 { print("Done") }
}
It might be worth combining the animate function with an init method.. it's not a huge deal though.
Here is a swift implementation, with comments explaining why this is needed.
The idea is the same as in devdavid's answer, only the implementation approach is different.
/*
Animated use of `scrollToContentOffset:animated:` doesn't give enough control over the animation duration and curve.
Non-animated use of `scrollToContentOffset:animated:` (or contentOffset directly) embedded in an animation block gives more control but interfer with the internal logic of UICollectionView. For example, cells that are not visible for the target contentOffset are removed at the beginning of the animation because from the collection view point of view, the change is not animated and the cells can safely be removed.
To fix that, we must control the scroll ourselves. We use CADisplayLink to update the scroll offset step-by-step and render cells if needed alongside. To simplify, we force a linear animation curve, but this can be adapted if needed.
*/
private var currentScrollDisplayLink: CADisplayLink?
private var currentScrollStartTime = Date()
private var currentScrollDuration: TimeInterval = 0
private var currentScrollStartContentOffset: CGFloat = 0.0
private var currentScrollEndContentOffset: CGFloat = 0.0
// The curve is hardcoded to linear for simplicity
private func beginAnimatedScroll(toContentOffset contentOffset: CGPoint, animationDuration: TimeInterval) {
// Cancel previous scroll if needed
resetCurrentAnimatedScroll()
// Prevent non-animated scroll
guard animationDuration != 0 else {
logAssertFail("Animation controlled scroll must not be used for non-animated changes")
collectionView?.setContentOffset(contentOffset, animated: false)
return
}
// Setup new scroll properties
currentScrollStartTime = Date()
currentScrollDuration = animationDuration
currentScrollStartContentOffset = collectionView?.contentOffset.y ?? 0.0
currentScrollEndContentOffset = contentOffset.y
// Start new scroll
currentScrollDisplayLink = CADisplayLink(target: self, selector: #selector(handleScrollDisplayLinkTick))
currentScrollDisplayLink?.add(to: RunLoop.current, forMode: .commonModes)
}
#objc
private func handleScrollDisplayLinkTick() {
let animationRatio = CGFloat(abs(currentScrollStartTime.timeIntervalSinceNow) / currentScrollDuration)
// Animation is finished
guard animationRatio < 1 else {
endAnimatedScroll()
return
}
// Animation running, update with incremental content offset
let deltaContentOffset = animationRatio * (currentScrollEndContentOffset - currentScrollStartContentOffset)
let newContentOffset = CGPoint(x: 0.0, y: currentScrollStartContentOffset + deltaContentOffset)
collectionView?.setContentOffset(newContentOffset, animated: false)
}
private func endAnimatedScroll() {
let newContentOffset = CGPoint(x: 0.0, y: currentScrollEndContentOffset)
collectionView?.setContentOffset(newContentOffset, animated: false)
resetCurrentAnimatedScroll()
}
private func resetCurrentAnimatedScroll() {
currentScrollDisplayLink?.invalidate()
currentScrollDisplayLink = nil
}
I suspect that UICollectionView is trying to improve performance by waiting until the end of the scroll before updating.
Perhaps you could divide the animation up into chucks, although I'm not sure how smooth that would be.
Or maybe calling setNeedsDisplay periodically during the scroll?
Alternatively, perhaps this replacement for UICollectionView will either do want you need or else can be modified to do so:
https://github.com/steipete/PSTCollectionView
If you need to start animation before user start dragging UICollectionView (e.g. from one page to another page), you can use this workaround to preload side cells:
func scroll(to index: Int, progress: CGFloat = 0) {
let isInsideAnimation = UIView.inheritedAnimationDuration > 0
if isInsideAnimation {
// workaround
// preload left & right cells
// without this, some cells will be immediately removed before animation starts
preloadSideCells()
}
collectionView.contentOffset.x = (CGFloat(index) + progress) * collectionView.bounds.width
if isInsideAnimation {
// workaround
// sometimes invisible cells not removed (because of side cells preloading)
// without this, some invisible cells will persists on superview after animation ends
removeInvisibleCells()
UIView.performWithoutAnimation {
self.collectionView.layoutIfNeeded()
}
}
}
private func preloadSideCells() {
collectionView.contentOffset.x -= 0.5
collectionView.layoutIfNeeded()
collectionView.contentOffset.x += 1
collectionView.layoutIfNeeded()
}
private func removeInvisibleCells() {
let visibleCells = collectionView.visibleCells
let visibleRect = CGRect(
x: max(0, collectionView.contentOffset.x - collectionView.bounds.width),
y: collectionView.contentOffset.y,
width: collectionView.bounds.width * 3,
height: collectionView.bounds.height
)
for cell in visibleCells {
if !visibleRect.intersects(cell.frame) {
cell.removeFromSuperview()
}
}
}
Without this workaround, UICollectionView will remove cells, that not intersects target bounds, before the animation starts.
P.S. This working only if you need animate to next or previous page.
Use :scrollToItemAtIndexPath instead:
[UIView animateWithDuration:duration animations:^{
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
}];

adding inertia to a UIPanGestureRecognizer

I am trying to move a sub view across the screen which works, but i also want to add inertia or momentum to the object.
My UIPanGestureRecognizer code that i already have is below.
Thanks in advance.
UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(handlePan:)];
[self addGestureRecognizer:panGesture];
(void)handlePan:(UIPanGestureRecognizer *)recognizer
{
CGPoint translation = [recognizer translationInView:self.superview];
recognizer.view.center = CGPointMake(recognizer.view.center.x + translation.x,
recognizer.view.center.y + translation.y);
[recognizer setTranslation:CGPointMake(0, 0) inView:self.superview];
if (recognizer.state == UIGestureRecognizerStateEnded) {
[self.delegate card:self.tag movedTo:self.frame.origin];
}
}
Again thanks.
Have a look at RotationWheelAndDecelerationBehaviour. there is an example for how to do the deceleration for both linear panning and rotational movement. Trick is to see what is the velocity when user ends the touch and continue in that direction with a small deceleration.
Well, I'm not a pro but, checking multiple answers, I managed to make my own code with which I am happy.
Please tell me how to improve it and if there are any bad practices I used.
- (IBAction)handlePan:(UIPanGestureRecognizer *)recognizer {
CGPoint translatedPoint = [recognizer translationInView:self.postViewContainer];
CGPoint velocity = [recognizer velocityInView:recognizer.view];
float bottomMargin = self.view.frame.size.height - containerViewHeight;
float topMargin = self.view.frame.size.height - scrollViewHeight;
if ([recognizer state] == UIGestureRecognizerStateChanged) {
newYOrigin = self.postViewContainer.frame.origin.y + translatedPoint.y;
if (newYOrigin <= bottomMargin && newYOrigin >= topMargin) {
self.postViewContainer.center = CGPointMake(self.postViewContainer.center.x, self.postViewContainer.center.y + translatedPoint.y);
}
[recognizer setTranslation:CGPointMake(0, 0) inView:self.postViewContainer];
}
if ([recognizer state] == UIGestureRecognizerStateEnded) {
__block float newYAnimatedOrigin = self.postViewContainer.frame.origin.y + (velocity.y / 2.5);
if (newYAnimatedOrigin <= bottomMargin && newYAnimatedOrigin >= topMargin) {
[UIView animateWithDuration:1.2 delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^ {
self.postViewContainer.center = CGPointMake(self.postViewContainer.center.x, self.postViewContainer.center.y + (velocity.y / 2.5));
}
completion:^(BOOL finished) {
[self.postViewContainer setFrame:CGRectMake(0, newYAnimatedOrigin, self.view.frame.size.width, self.view.frame.size.height - newYAnimatedOrigin)];
}
];
}
else {
[UIView animateWithDuration:0.6 delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^ {
if (newYAnimatedOrigin > bottomMargin) {
self.postViewContainer.center = CGPointMake(self.postViewContainer.center.x, bottomMargin + self.postViewContainer.frame.size.height / 2);
}
if (newYAnimatedOrigin < topMargin) {
self.postViewContainer.center = CGPointMake(self.postViewContainer.center.x, topMargin + self.postViewContainer.frame.size.height / 2);
}
}
completion:^(BOOL finished) {
if (newYAnimatedOrigin > bottomMargin)
[self.postViewContainer setFrame:CGRectMake(0, bottomMargin, self.view.frame.size.width, scrollViewHeight)];
if (newYAnimatedOrigin < topMargin)
[self.postViewContainer setFrame:CGRectMake(0, topMargin, self.view.frame.size.width, scrollViewHeight)];
}
];
}
}
}
I have used two different animation, one is the default inertia one and the other if for when the user flings the containerView with high velocity.
It works well under iOS 7.
I took the inspiration from the accepted answer's implementation. Here is a Swift 5.1 version.
Logic:
You need to calculate the angle changes with the velocity at which the pan gesture ended and keep rotating the wheel in an endless timer until the velocity wears down because of deceleration rate.
Keep decreasing the current velocity in every iteration of the timer
with some factor (say, 0.9).
Keep a lower limit on the velocity to
invalidate the timer and complete the deceleration process.
Main function used to calculate deceleration:
// deceleration behaviour constants (change these for different deceleration rates)
private let timerDuration = 0.025
private let decelerationSmoothness = 0.9
private let velocityToAngleConversion = 0.0025
private func animateWithInertia(velocity: Double) {
_ = Timer.scheduledTimer(withTimeInterval: self.timerDuration, repeats: true) { [weak self] timer in
guard let this = self else {
return
}
let concernedVelocity = this.currentVelocity == 0.0 ? velocity : this.currentVelocity
let newVelocity = concernedVelocity * this.decelerationSmoothness
this.currentVelocity = newVelocity
var angleTraversed = newVelocity * this.velocityToAngleConversion * this.maximumRotationAngleInCircle
if !this.isClockwiseRotation {
angleTraversed *= -1
}
// exit condition
if newVelocity < 0.1 {
timer.invalidate()
this.currentVelocity = 0.0
} else {
this.traverseAngularDistance(angle: angleTraversed)
}
}
}
Full working code with helper functions, extensions and usage of aforementioned function in the handlePanGesture function.
// deceleration behaviour constants (change these for different deceleration rates)
private let timerDuration = 0.025
private let decelerationSmoothness = 0.9
private let velocityToAngleConversion = 0.0025
private let maximumRotationAngleInCircle = 360.0
private var currentRotationDegrees: Double = 0.0 {
didSet {
if self.currentRotationDegrees > self.maximumRotationAngleInCircle {
self.currentRotationDegrees = 0
}
if self.currentRotationDegrees < -self.maximumRotationAngleInCircle {
self.currentRotationDegrees = 0
}
}
}
private var previousLocation = CGPoint.zero
private var currentLocation = CGPoint.zero
private var velocity: Double {
let xFactor = self.currentLocation.x - self.previousLocation.x
let yFactor = self.currentLocation.y - self.previousLocation.y
return Double(sqrt((xFactor * xFactor) + (yFactor * yFactor)))
}
private var currentVelocity = 0.0
private var isClockwiseRotation = false
#objc private func handlePanGesture(panGesture: UIPanGestureRecognizer) {
let location = panGesture.location(in: self)
if let rotation = panGesture.rotation {
self.isClockwiseRotation = rotation > 0
let angle = Double(rotation).degrees
self.currentRotationDegrees += angle
self.rotate(angle: angle)
}
switch panGesture.state {
case .began, .changed:
self.previousLocation = location
case .ended:
self.currentLocation = location
self.animateWithInertia(velocity: self.velocity)
default:
print("Fatal State")
}
}
private func animateWithInertia(velocity: Double) {
_ = Timer.scheduledTimer(withTimeInterval: self.timerDuration, repeats: true) { [weak self] timer in
guard let this = self else {
return
}
let concernedVelocity = this.currentVelocity == 0.0 ? velocity : this.currentVelocity
let newVelocity = concernedVelocity * this.decelerationSmoothness
this.currentVelocity = newVelocity
var angleTraversed = newVelocity * this.velocityToAngleConversion * this.maximumRotationAngleInCircle
if !this.isClockwiseRotation {
angleTraversed *= -1
}
if newVelocity < 0.1 {
timer.invalidate()
this.currentVelocity = 0.0
this.selectAtIndexPath(indexPath: this.nearestIndexPath, shouldTransformToIdentity: true)
} else {
this.traverseAngularDistance(angle: angleTraversed)
}
}
}
private func traverseAngularDistance(angle: Double) {
// keep the angle in -360.0 to 360.0 range
let times = Double(Int(angle / self.maximumRotationAngleInCircle))
var newAngle = angle - times * self.maximumRotationAngleInCircle
if newAngle < -self.maximumRotationAngleInCircle {
newAngle += self.maximumRotationAngleInCircle
}
self.currentRotationDegrees += newAngle
self.rotate(angle: newAngle)
}
Extensions being used in above code:
extension UIView {
func rotate(angle: Double) {
self.transform = self.transform.rotated(by: CGFloat(angle.radians))
}
}
extension Double {
var radians: Double {
return (self * Double.pi)/180
}
var degrees: Double {
return (self * 180)/Double.pi
}
}

UISlider that snaps to a fixed number of steps (like Text Size in the iOS 7 Settings app)

I'm trying to create a UISlider that lets you choose from an array of numbers. Each slider position should be equidistant and the slider should snap to each position, rather than smoothly slide between them. (This is the behavior of the slider in Settings > General > Text Size, which was introduced in iOS 7.)
The numbers I want to choose from are: -3, 0, 2, 4, 7, 10, and 12.
(I'm very new to Objective-C, so a complete code example would be much more helpful than a code snippet. =)
Some of the other answers work, but this will give you the same fixed space between every position in your slider. In this example you treat the slider positions as indexes to an array which contains the actual numeric values you are interested in.
#interface MyViewController : UIViewController {
UISlider *slider;
NSArray *numbers;
}
#end
#implementation MyViewController
- (void)viewDidLoad {
[super viewDidLoad];
slider = [[UISlider alloc] initWithFrame:self.view.bounds];
[self.view addSubview:slider];
// These number values represent each slider position
numbers = #[#(-3), #(0), #(2), #(4), #(7), #(10), #(12)];
// slider values go from 0 to the number of values in your numbers array
NSInteger numberOfSteps = ((float)[numbers count] - 1);
slider.maximumValue = numberOfSteps;
slider.minimumValue = 0;
// As the slider moves it will continously call the -valueChanged:
slider.continuous = YES; // NO makes it call only once you let go
[slider addTarget:self
action:#selector(valueChanged:)
forControlEvents:UIControlEventValueChanged];
}
- (void)valueChanged:(UISlider *)sender {
// round the slider position to the nearest index of the numbers array
NSUInteger index = (NSUInteger)(slider.value + 0.5);
[slider setValue:index animated:NO];
NSNumber *number = numbers[index]; // <-- This numeric value you want
NSLog(#"sliderIndex: %i", (int)index);
NSLog(#"number: %#", number);
}
Edit: Here's a version in Swift 4 that subclasses UISlider with callbacks.
class MySliderStepper: UISlider {
private let values: [Float]
private var lastIndex: Int? = nil
let callback: (Float) -> Void
init(frame: CGRect, values: [Float], callback: #escaping (_ newValue: Float) -> Void) {
self.values = values
self.callback = callback
super.init(frame: frame)
self.addTarget(self, action: #selector(handleValueChange(sender:)), for: .valueChanged)
let steps = values.count - 1
self.minimumValue = 0
self.maximumValue = Float(steps)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#objc func handleValueChange(sender: UISlider) {
let newIndex = Int(sender.value + 0.5) // round up to next index
self.setValue(Float(newIndex), animated: false) // snap to increments
let didChange = lastIndex == nil || newIndex != lastIndex!
if didChange {
lastIndex = newIndex
let actualValue = self.values[newIndex]
self.callback(actualValue)
}
}
}
If you have regular spaces between steps, you can use it like this for 15 value:
#IBAction func timeSliderChanged(sender: UISlider) {
let newValue = Int(sender.value/15) * 15
sender.setValue(Float(newValue), animated: false)
}
This worked for my particular case, using #IBDesignable:
Requires even, integer intervals:
#IBDesignable
class SnappableSlider: UISlider {
#IBInspectable
var interval: Int = 1
override init(frame: CGRect) {
super.init(frame: frame)
setUpSlider()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setUpSlider()
}
private func setUpSlider() {
addTarget(self, action: #selector(handleValueChange(sender:)), for: .valueChanged)
}
#objc func handleValueChange(sender: UISlider) {
let newValue = (sender.value / Float(interval)).rounded() * Float(interval)
setValue(Float(newValue), animated: false)
}
}
The answer is essentially the same at this answer to UISlider with increments of 5
To modify it to work for your case, you'll need to create a rounding function that returns only the values you want. For example, you could do something simple (though hacky) like this:
-(int)roundSliderValue:(float)x {
if (x < -1.5) {
return -3;
} else if (x < 1.0) {
return 0;
} else if (x < 3.0) {
return 2;
} else if (x < 5.5) {
return 4;
} else if (x < 8.5) {
return 7;
} else if (x < 11.0) {
return 10;
} else {
return 12;
}
}
Now use the answer from the previous post to round the value.
slider.continuous = YES;
[slider addTarget:self
action:#selector(valueChanged:)
forControlEvents:UIControlEventValueChanged];
Finally, implement the changes:
-(void)valueChanged:(id)slider {
[slider setValue:[self roundSliderValue:slider.value] animated:NO];
}
If anyone also needs snaping animation then can do the following:
Uncheck the continuous update of the slider from the storyboard.
You can do the same from swift slider.isContinuous = false
Add the following #IBAction in your ViewController:
#IBAction func sliderMoved(_ slider: UISlider){
let stepCount = 10
let roundedCurrent = (slider.value/Float(stepCount)).rounded()
let newValue = Int(roundedCurrent) * stepCount
slider.setValue(Float(newValue), animated: true)
}
I was inspired by this answer
Similar approach as PengOne, but I did manual rounding to make it more clear what was happening.
- (IBAction)sliderDidEndEditing:(UISlider *)slider {
// default value slider will start at
float newValue = 0.0;
if (slider.value < -1.5) {
newValue = -3;
} else if (slider.value < 1) {
newValue = 0;
} else if (slider.value < 3) {
newValue = 2;
} else if (slider.value < 5.5) {
newValue = 4;
} else if (slider.value < 8.5) {
newValue = 7;
} else if (slider.value < 11) {
newValue = 10;
} else {
newValue = 12;
}
slider.value = newValue;
}
- (IBAction)sliderValueChanged:(id)sender {
UISlider *slider = sender;
float newValue = 0.0;
if (slider.value < -1.5) {
newValue = -3;
} else if (slider.value < 1) {
newValue = 0;
} else if (slider.value < 3) {
newValue = 2;
} else if (slider.value < 5.5) {
newValue = 4;
} else if (slider.value < 8.5) {
newValue = 7;
} else if (slider.value < 11) {
newValue = 10;
} else {
newValue = 12;
}
// and if you have a label displaying the slider value, set it
[yourLabel].text = [NSString stringWithFormat:#"%d", (int)newValue];
}

Resources