UILabel when inactive, UISlider when active

I’m working on a new app and I’m trying to keep the interface minimal in terms of screen elements.  Also, design-wise I’m a huge fan of finding ways to only use space *when* you need it, and finding novel ways to conceal elements that you don’t need (when you don’t need them).  On the flipside, one should also be conscious of hiding too much, thus alienating some users who don’t immediately have a good sense for such things.  That addresses the need for good onboarding but that’s a longer discussion.

So I thought, (the new app is a music player), “it’s not often you need to seek to a certain time in the song, so why does this element get to take up so much space?”

Since my app’s UX generally involves interacting with labels, I thought it would be interesting to hide a slider behind a label, but when you interact with it, it’s the underlying control that becomes active.

I think I should just show the code.  The key is in how one overrides the method -hitTest:withEvent:  Because of that, this solution is very flexible in terms of what kind of UISlider you use, and what kind of UILabel you use.  It’s the technique worth noting because it could have a few applications.

Shoot me a message if you don’t quite understand why this does what it does, or why a different approach might / might not work for you.

(Oh yeah, the assumption is that the subviews are pinned to their parent’s edges… i.e. same frame.size)

/  LabelScrubber.swift
//  LabelScrubber
//
//  Created by Stephen O'Connor on 03.11.20.
//  Copyright © 2020 HomeTeam Software. All rights reserved.
//

import UIKit

class LabelScrubber: UIView {

    let showDelayShort = 0.7
    let showDelayLong = 1.2
    
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var slider: UISlider!
    
    var labelTimer: Timer?
    
    /// this gets called on a touch down, so in other words, at the beginning of an interaction
    /// but you tell it which view you want to be 'the view' for the interaction.
    /// super cool; so you can override and say it's the slider that matters
    /// and thus you'll interact with that.
    /// nice though that we can make preparations before that interaction,
    /// like snapping the slider to where your touch is!
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if self.bounds.contains(point) {
            self.slider.isHidden = false
            self.label.isHidden = true
            
            let percentX = point.x / self.bounds.size.width
        
            let targetValue = self.slider.minimumValue + Float(percentX) * (self.slider.maximumValue - self.slider.minimumValue)
            
            self.slider.setValue(targetValue, animated: true)
            
            showLabel(after: showDelayLong)
            return self.slider
        }
        return super.hitTest(point, with: event)
    }
    
    override var intrinsicContentSize: CGSize {
        return label.intrinsicContentSize
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        commonInit()
    }
    
    private func commonInit() {
        self.label.isUserInteractionEnabled = false
        self.label.isHidden = false
        self.slider.isHidden = true
        self.slider.addTarget(self, action: #selector(tracking(_:)), for: .touchDown)
        self.slider.addTarget(self, action: #selector(tracking(_:)), for: .valueChanged)
        self.slider.addTarget(self, action: #selector(finishedTracking(_:)), for: .touchUpInside)
        self.slider.addTarget(self, action: #selector(finishedTracking(_:)), for: .touchUpOutside)
    }
    
    @objc
    func finishedTracking(_ slider: UISlider) {
        showLabel(after: showDelayShort)
    }
    
    @objc
    func tracking(_ slider: UISlider) {
        invalidateShowTimer()
    }
    
    private func showLabel(after delay: TimeInterval) {
        print("willShowLabel")
        invalidateShowTimer()
        labelTimer = Timer.scheduledTimer(withTimeInterval: delay,
                                          repeats: false,
                                          block:
            { [weak self] (_) in
                print("showLabel\n\n")
                self?.returnToDefaultState()
                
        })
    }
    
    func invalidateShowTimer() {
        print("invalidate")
        labelTimer?.invalidate()
    }
    
    func returnToDefaultState(duration: TimeInterval = 0.3) {

        self.slider?.alpha = 1.0
        UIView.animate(withDuration: duration,
                       animations: {
                        //self.label?.alpha = 1.0
                        self.slider?.alpha = 0.0
        }) { [weak self] (_) in
            self?.label?.isHidden = false
            self?.slider?.isHidden = true
            self?.slider?.alpha = 1.0
        }
    }
}

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s