Search code examples
iosswiftuiscrollviewscroll-paging

Page UIScrollview item one at a time


I'm trying to mimic the scrolling experience of iOS Camera app options (video, photo, portrait, etc). When scrolling, the camera options page only one at a time.

enter image description here

So far this is what I have. As you can see in the demo below the paging stops on multiples of the scroll view’s bounds when the user scrolls and not like the Camera app.

class ViewController: UIViewController {
    lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.spacing = 50
        return stackView
    }()
    
    lazy var scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.isPagingEnabled = true
        return scrollView
    }()
    
    let options = ["Time-lapse", "Slo-Mo", "Cinematic", "Video", "Photo", "Portrait", "Pano"]
    
    override func loadView() {
        let view = UIView()
        self.view = view
        view.backgroundColor = .white
        view.addSubview(scrollView)
        scrollView.addSubview(stackView)

        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100).isActive = true
        scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        scrollView.heightAnchor.constraint(equalToConstant: 50).isActive = true

        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
        stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
        stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
        
        for option in options {
            let label = UILabel()
            label.text = option.uppercased()
            stackView.addArrangedSubview(label)
        }
        
        scrollView.contentInset = UIEdgeInsets(top: 0, left: UIScreen.main.bounds.width/2, bottom: 0, right: UIScreen.main.bounds.width/2)
    }
}

enter image description here

How do I fix this?


Solution

  • It doesn't look like the "selection panel" in the Camera app is using a UIScrollView...

    Notice that we cannot drag-and-scroll more than one item.

    Here is one approach, using a UIPanGestureRecognizer and a UITapGetureRecognizer...

    We will:

    • create a UIView subclass
    • create labels for each option
    • "chain" the labels together with constraints (rather than using a stack view)
    • to center a "selected" label, we can set its center-X constraint to our custom view's center-X, and animate

    It will look like this when running:

    enter image description here


    Custom UIView subclass:

    class SelectLabelPanelView: UIView {
        
        // so we can inform the controller that the selection changed
        public var callbackClosure: ((Int) -> ())?
        
        public var theLabelTitles: [String] = [] {
            didSet {
                for v in self.subviews {
                    v.removeFromSuperview()
                }
                self.theLabels = []
                for str in theLabelTitles {
                    let v = UILabel()
                    v.text = str.uppercased()
                    v.font = self.theNormalFont
                    v.textColor = self.theNormalFontColor
                    
                    v.translatesAutoresizingMaskIntoConstraints = false
                    self.addSubview(v)
                    self.theLabels.append(v)
                    
                    v.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
                    v.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
                }
                var prevV: UIView!
                for (i, v) in self.theLabels.enumerated() {
                    if i > 0 {
                        v.leadingAnchor.constraint(equalTo: prevV.trailingAnchor, constant: self.spacing).isActive = true
                    }
                    prevV = v
                }
                self.centerConstraint = self.theLabels[self.selItem].centerXAnchor.constraint(equalTo: self.centerXAnchor)
                self.centerConstraint.isActive = true
                self.theLabels[self.selItem].font = self.theSelectedFont
                self.theLabels[self.selItem].textColor = self.theSelectedFontColor
            }
        }
        
        // public properties to set font, color and spacing
        public var theFont: UIFont = .systemFont(ofSize: 16.0) {
            didSet {
                self.theNormalFont = theFont
                // make selected font the same, but Bold
                var symTraits = theFont.fontDescriptor.symbolicTraits
                symTraits.insert([.traitBold])
                self.theSelectedFont = UIFont(descriptor: theFont.fontDescriptor.withSymbolicTraits(symTraits)!, size: theNormalFont.pointSize)
                self.updateLabels()
            }
        }
        public var theFontColor: UIColor = .white {
            didSet {
                self.theSelectedFontColor = theFontColor
                // make normal font color the same, but with 90% alpha
                self.theNormalFontColor = theFontColor.withAlphaComponent(0.9)
                self.updateLabels()
            }
        }
        
        // private properties with defaults
        private var theNormalFont: UIFont = .systemFont(ofSize: 16.0, weight: .regular)
        private var theSelectedFont: UIFont = .systemFont(ofSize: 16.0, weight: .bold)
        private var theNormalFontColor: UIColor = .white.withAlphaComponent(0.9)
        private var theSelectedFontColor: UIColor = .white
        
        // private vars
        private var theLabels: [UILabel] = []
        private var spacing: CGFloat = 24.0
        private var centerConstraint: NSLayoutConstraint!
        private var selItem: Int = 0
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            let pg = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
            let tg = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
            self.addGestureRecognizer(pg)
            self.addGestureRecognizer(tg)
        }
        
        @objc func handlePan(_ g: UIPanGestureRecognizer) {
            if g.state == .began {
                self.selectItem(g.velocity(in: self).x > 0 ? selItem - 1 : selItem + 1)
            }
        }
        @objc func handleTap(_ g: UITapGestureRecognizer) {
            let loc = g.location(in: self)
            // find the tapped label
            for i in 0..<self.theLabels.count {
                if self.theLabels[i].frame.contains(loc) {
                    self.selectItem(i)
                    break
                }
            }
        }
        private func selectItem(_ i: Int) {
            if i >= self.theLabels.count || i < 0 {
                return
            }
            self.selItem = i
            self.centerConstraint.isActive = false
            self.centerConstraint = theLabels[self.selItem].centerXAnchor.constraint(equalTo: self.centerXAnchor)
            self.centerConstraint.isActive = true
            UIView.animate(withDuration: 0.3, animations: {
                self.layoutIfNeeded()
            }, completion: { _ in
                self.updateLabels()
                self.callbackClosure?(self.selItem)
            })
        }
        private func updateLabels() {
            for v in self.theLabels {
                v.font = self.theNormalFont
                v.textColor = self.theNormalFontColor
            }
            self.theLabels[self.selItem].font = self.theSelectedFont
            self.theLabels[self.selItem].textColor = self.theSelectedFontColor
        }
        
        // so we can set the selected item from the controller
        public func setSelected(_ i: Int) {
            self.selectItem(i)
        }
    }
    

    Example View Controller:

    class LabelPanelVC: UIViewController {
        
        let panelView = SelectLabelPanelView()
        
        let options: [String] = ["Time-lapse", "Slo-Mo", "Cinematic", "Video", "Photo", "Portrait", "Pano"]
        let colors: [UIColor] = [.systemRed, .systemGreen, .systemBlue, .cyan, .yellow, .magenta, .systemBrown]
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = colors[0]
    
            panelView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(panelView)
            
            NSLayoutConstraint.activate([
                panelView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
                panelView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                panelView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                panelView.heightAnchor.constraint(equalToConstant: 50),
            ])
    
            panelView.theLabelTitles = options
            
            panelView.callbackClosure = { [weak self] idx in
                guard let self = self else { return }
                
                // a label was selected... either
                //  tapped or
                //  dragged to the center
                
                // do something based on the selected index
                
                print("Item: \(idx) / \(options[idx]) was selected!")
                
                // make sure we don't exceed the colors bounds
                self.view.backgroundColor = self.colors[idx % self.colors.count]
            }
            
            // so we can see the panelView framing
            panelView.backgroundColor = .darkGray
    
            // we can change some default properties, if desired
            //panelView.theFont = .italicSystemFont(ofSize: 16.0)
            //panelView.theFontColor = .yellow
        }
    
    }