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

6

66

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.)

iOS 7 Text Size Slider

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. =)

Wateriness answered 21/11, 2011 at 21:56 Comment(4)
possible duplicate of How to UISlider with increments of 5Faretheewell
No it isn't. Since I don't a fixed step of 5 or 10. I need the ability to choose from 7 fixed values on the slider. Also, if I actually can use the one you linked to, I don't know how to implement the code and change it for my fitting. It may be the correct way to do it, but I need elaboration.Wateriness
The highly upvoted answer to the linked question can be adapted to your needs. Why don't you try that, and then ask again if you have any problems with it?Faretheewell
Okay, that's good news. But can you please elaborate to me, perhaps in an answer to this question, providing a sample code, showing me how to adapt the code to my needs? Where should I add the delegate? And how do I get the value to change so it will fit into my 7 different ticks? As I mentioned in my question I am fairly new to Objective-C - I can't just look at a code and think "Ahh, I can change this and then...", yet :)Wateriness
P
130

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)
        }
    }
}
Protective answered 21/11, 2011 at 23:9 Comment(6)
Thank you very much. This is exactly what I wanted and written in a way that teaches me how to do it. Thank you again!Wateriness
So i changed "numbers" to an array with over 100 data points.... How do i change the code for the UISlider to slide with respect the amount of data points there are? I understand that "index" would need to change but .... :/.... I figure I will find out the answer within a few hours but I think a few others would like to see an answer for a DYNAMIC array, where the slider can have 1 index to thousands.... :)Interlocution
You get an addition upvote for simple changing to your code to accept static or dynamic array values :)Interlocution
If you set it to continuous values = NO slider.continuous = NO; it allows a smoother action for the user while dragging the slider. Since it only evaluates the valueChanged function when the user finishes dragging and moving the slider to the closest integer value.Fusain
@MatiasVad I need same UI like in question, i mean the small steps vertical line, i searched but not found solution for it.Animation
A smoother experience can be fulfilled by snapping to the new value only if (!self.isTracking). that is, only when the user stops drugging. This way the virtual thumb always follows the finger and only when the gesture is done it jumps to the nearest valid value.Pennington
M
9

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)
}
Maibach answered 7/1, 2016 at 14:8 Comment(1)
This is easy to implement and a great option for simple cases. I think the accepted answer (while still a good option and certainly more customizable) is overkill for most cases and this should be enough for what most people want to do.Septuagint
P
8

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)
    }
}
Piebald answered 9/1, 2019 at 19:21 Comment(2)
Awesome 👏🏼 you did it very simple.Gnawing
This has got to be the easiest solution.Orphaorphan
U
2

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];
}
Unmusical answered 21/11, 2011 at 22:37 Comment(0)
V
2

If anyone also needs snaping animation then can do the following:

  1. Uncheck the continuous update of the slider from the storyboard. Storyboard

You can do the same from swift slider.isContinuous = false

  1. 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

Victorious answered 29/5, 2020 at 0:31 Comment(0)
A
0

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];
}
Axolotl answered 14/11, 2014 at 23:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.