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.
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)
}
}
How do I fix this?
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:
UIView
subclassIt will look like this when running:
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
}
}