I recently started with iOS development, and I'm currently working on an existing iOS Swift app with the intention of adding additional functionality. The current view contains a custom header and footer view, and the idea is for me to add the new slider with discrete steps in between, which worked. However, now I would also like to add labels to describe the discrete UISlider, for example having "Min" and "Max" to the left and right respectively, as well as the value of current value of the slider:
To achieve this, I was thinking to define a UITableView and a custom cell where I would insert the slider, while the labels could be defined in a row above or below the slider row. In my recent attempt I tried to define the table view and simply add the same slider element to a row, but I'm unsure how to proceed.
In addition, there is no Storyboard, everything has to be done programatically. Here is the sample code for my current version:
Slider and slider view definition:
private var sliderView = UIView()
private var discreteSlider = UISlider()
private let step: Float = 1 // for UISlider to snap in steps
Table view definition:
// temporary table view rows. For testing the table view
private let myArray: NSArray = ["firstRow", "secondRow"]
private lazy var tableView: UITableView = {
let displayWidth: CGFloat = self.view.frame.width
let displayHeight: CGFloat = self.view.frame.height / 3
let yPos = headerHeight
myTableView = UITableView(frame: CGRect(x: 0, y: yPos, width: displayWidth, height: displayHeight))
myTableView.backgroundColor = .clear
myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "MyCell")
myTableView.dataSource = self
myTableView.delegate = self
return myTableView
}()
Loading the views:
private func setUpView() {
// define slider
discreteSlider = UISlider(frame:CGRect(x: 0, y: 0, width: 250, height: 20))
// define slider properties
discreteSlider.center = self.view.center
discreteSlider.minimumValue = 1
discreteSlider.maximumValue = 5
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
sliderView.addSubviews(discreteSlider) // add the slider to its view
UIView.animate(withDuration: 0.8) {
self.discreteSlider.setValue(2.0, animated: true)
}
//////
// Add the slider, labels to table rows here
// Add the table view to the main view
view.addSubviews(headerView, tableView, footerView)
//////
//current version without the table
//view.addSubviews(headerView, sliderView, footerView)
headerView.title = "View Title". // header configuration
}
Class extension for the table view:
extension MyViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print("Num: \(indexPath.row)")
print("Value: \(myArray[indexPath.row])")
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return myArray.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath as IndexPath)
cell.textLabel!.text = "\(myArray[indexPath.row])"
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
}
Furthermore, if there is a better solution that the UITableView approach, I would be willing to try. I also started to look over UICollectionView. Thanks!
While you could put these elements in different rows / cells of a table view, that's not what table views are designed for and there is a much better approach.
Create a UIView subclass and use auto-layout constraints to position the elements:
We use a horizontal UIStackView for the "step" labels... Distribution is set to .equalSpacing and we constrain the labels to all be equal widths.
We constrain the slider above the stack view, constraining its Leading and Trailing to the centerX of the first and last step labels (with +/- offsets for the width of the thumb).
We constrain the centerX of the Min and Max labels to the centerX of the first and last step labels.
Here is an example:
class MySliderView: UIView {
private var discreteSlider = UISlider()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let minVal: Int = 1
let maxVal: Int = 5
// slider properties
discreteSlider.minimumValue = Float(minVal)
discreteSlider.maximumValue = Float(maxVal)
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
let stepStack = UIStackView()
stepStack.distribution = .equalSpacing
for i in minVal...maxVal {
let v = UILabel()
v.text = "\(i)"
v.textAlignment = .center
v.textColor = .systemRed
stepStack.addArrangedSubview(v)
}
// references to first and last step label
guard let firstLabel = stepStack.arrangedSubviews.first,
let lastLabel = stepStack.arrangedSubviews.last
else {
// this will never happen, but we want to
// properly unwrap the labels
return
}
// make all step labels the same width
stepStack.arrangedSubviews.dropFirst().forEach { v in
v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
}
let minLabel = UILabel()
minLabel.text = "Min"
minLabel.textAlignment = .center
minLabel.textColor = .systemRed
let maxLabel = UILabel()
maxLabel.text = "Max"
maxLabel.textAlignment = .center
maxLabel.textColor = .systemRed
// add the labels and the slider to self
[minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
// now we setup the layout
NSLayoutConstraint.activate([
// start with the step labels stackView
// we'll give it 40-pts leading and trailing "padding"
stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
// and 20-pts from the bottom
stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),
// now constrain the slider leading and trailing to the
// horizontal center of first and last step labels
// accounting for width of thumb (assuming a default UISlider)
discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
// and 20-pts above the steps stackView
discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
// constrain Min and Max labels centered to first and last step labels
minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
// and 20-pts above the steps slider
minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
// and 20-pts top "padding"
minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
])
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)
}
// so we can set the slider value from the controller
public func setSliderValue(_ val: Float) -> Void {
discreteSlider.setValue(val, animated: true)
}
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
print("Slider dragging value:", sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(lroundf(sender.value)), animated: true)
print("Slider dragging end value:", sender.value)
}
}
and it ends up looking like this:
Note that the target action for the slider value change is contained inside our custom class.
So, we need to provide functionality so our class can inform the controller when the slider value has changed.
The best way to do that is with closures...
We'll define the closures at the top of our MySliderView class:
class MySliderView: UIView {
// this closure will be used to inform the controller that
// the slider value changed
var sliderDraggingClosure: ((Float)->())?
var sliderReleasedClosure: ((Float)->())?
then in our slider action funcs, we can use that closure to "call back" to the controller:
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
// tell the controller
sliderDraggingClosure?(sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(lroundf(sender.value)), animated: true)
// tell the controller
sliderReleasedClosure?(sender.value)
}
and then in our view controller's viewDidLoad() func, we setup the closures:
// set the slider closures
mySliderView.sliderDraggingClosure = { [weak self] val in
print("Slider dragging value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
mySliderView.sliderReleasedClosure = { [weak self] val in
print("Slider dragging end value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
Here's the complete modified class (Edited to include Tap behavior):
class MySliderView: UIView {
// this closure will be used to inform the controller that
// the slider value changed
var sliderDraggingClosure: ((Float)->())?
var sliderReleasedClosure: ((Float)->())?
private var discreteSlider = UISlider()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let minVal: Int = 1
let maxVal: Int = 5
// slider properties
discreteSlider.minimumValue = Float(minVal)
discreteSlider.maximumValue = Float(maxVal)
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
let stepStack = UIStackView()
stepStack.distribution = .equalSpacing
for i in minVal...maxVal {
let v = UILabel()
v.text = "\(i)"
v.textAlignment = .center
v.textColor = .systemRed
stepStack.addArrangedSubview(v)
}
// references to first and last step label
guard let firstLabel = stepStack.arrangedSubviews.first,
let lastLabel = stepStack.arrangedSubviews.last
else {
// this will never happen, but we want to
// properly unwrap the labels
return
}
// make all step labels the same width
stepStack.arrangedSubviews.dropFirst().forEach { v in
v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
}
let minLabel = UILabel()
minLabel.text = "Min"
minLabel.textAlignment = .center
minLabel.textColor = .systemRed
let maxLabel = UILabel()
maxLabel.text = "Max"
maxLabel.textAlignment = .center
maxLabel.textColor = .systemRed
// add the labels and the slider to self
[minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
// now we setup the layout
NSLayoutConstraint.activate([
// start with the step labels stackView
// we'll give it 40-pts leading and trailing "padding"
stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
// and 20-pts from the bottom
stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),
// now constrain the slider leading and trailing to the
// horizontal center of first and last step labels
// accounting for width of thumb (assuming a default UISlider)
discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
// and 20-pts above the steps stackView
discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
// constrain Min and Max labels centered to first and last step labels
minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
// and 20-pts above the steps slider
minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
// and 20-pts top "padding"
minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
])
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)
// add tap gesture so user can either
// Drag the Thumb or
// Tap the slider bar
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(sliderTapped))
discreteSlider.addGestureRecognizer(tapGestureRecognizer)
}
// so we can set the slider value from the controller
public func setSliderValue(_ val: Float) -> Void {
discreteSlider.setValue(val, animated: true)
}
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
// tell the controller
sliderDraggingClosure?(sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(sender.value.rounded()), animated: true)
// tell the controller
sliderReleasedClosure?(sender.value)
}
#objc func sliderTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
guard let slider = gesture.view as? UISlider else { return }
// get tapped point
let pt: CGPoint = gesture.location(in: slider)
let widthOfSlider: CGFloat = slider.bounds.size.width
// calculate tapped point as percentage of width
let pct = pt.x / widthOfSlider
// convert to min/max value range
let pctRange = pct * CGFloat(slider.maximumValue - slider.minimumValue) + CGFloat(slider.minimumValue)
// "snap" to discreet step position
let newValue = Float(pctRange.rounded())
slider.setValue(newValue, animated: true)
// tell the controller
sliderReleasedClosure?(newValue)
}
}
along with an example view controller:
class SliderTestViewController: UIViewController {
let mySliderView = MySliderView()
override func viewDidLoad() {
super.viewDidLoad()
mySliderView.translatesAutoresizingMaskIntoConstraints = false
mySliderView.backgroundColor = .darkGray
view.addSubview(mySliderView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// let's put our custom slider view
// 40-pts from the top with
// 8-pts leading and trailing
mySliderView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
mySliderView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
mySliderView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
// we don't need Bottom or Height constraints, because our custom view's content
// will determine its Height
])
// set the slider closures
mySliderView.sliderDraggingClosure = { [weak self] val in
print("Slider dragging value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
mySliderView.sliderReleasedClosure = { [weak self] val in
print("Slider dragging end value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// start the slider at 4
UIView.animate(withDuration: 0.8) {
self.mySliderView.setSliderValue(4)
}
}
}
Edit 2
If you want to make the slider "tappable area" larger, use a subclassed UISlider and override point(inside, ...).
Example 1 - expand tap area 10-pts on each side, 15-pts top and bottom:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// expand tap area 10-pts on each side, 15-pts top and bottom
let bounds: CGRect = self.bounds.insetBy(dx: -10.0, dy: -15.0)
return bounds.contains(point)
}
}
Example 2 - expand tap area vertically to superview height:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var bounds: CGRect = self.bounds
if let sv = superview {
// expand tap area vertically to superview height
let svRect = sv.bounds
let f = self.frame
bounds.origin.y -= f.origin.y
bounds.size.height = svRect.height
}
return bounds.contains(point)
}
}
Example 3 - expand tap area both horizontally and vertically to include entire superview:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var bounds: CGRect = self.bounds
if let sv = superview {
// expand tap area both horizontally and vertically
// to include entire superview
let svRect = sv.bounds
let f = self.frame
bounds.origin.x -= f.origin.x
bounds.origin.y -= f.origin.y
bounds.size.width = svRect.width
bounds.size.height = svRect.height
}
return bounds.contains(point)
}
}
Note that if you're expanding the tap area horizontally (so the user can tap off the left/right ends of the slider), you'll also want to make sure your percentage / value calculation does not produce a value lower than the min, or higher than the max.
Related
My question and code is based on this answer to one of my previous questions. I have programmatically created stackview where several labels are stored and I'm trying to make these labels clickable. I tried two different solutions:
Make clickable label. I created function and assigned it to the label in the gesture recognizer:
public func setTapListener(_ label: UILabel){
let tapGesture: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureMethod(_:)))
tapGesture.numberOfTapsRequired = 1
tapGesture.numberOfTouchesRequired = 1
label.isUserInteractionEnabled = true
label.addGestureRecognizer(tapGesture)
}
#objc func tapGestureMethod(_ gesture: UITapGestureRecognizer) {
print(gesture.view?.tag)
}
but it does not work. Then below the second way....
I thought that maybe the 1st way does not work because the labels are in UIStackView so I decided to assign click listener to the stack view and then determine on which view we clicked. At first I assigned to each of labels in the stackview tag and listened to clicks:
let tap = UITapGestureRecognizer(target: self, action: #selector(didTapCard(sender:)))
labelsStack.addGestureRecognizer(tap)
....
#objc func didTapCard (sender: UITapGestureRecognizer) {
(sender.view as? UIStackView)?.arrangedSubviews.forEach({ label in
print((label as! UILabel).text)
})
}
but the problem is that the click listener works only on the part of the stack view and when I tried to determine on which view we clicked it was not possible.
I think that possibly the problem is with that I tried to assign one click listener to several views, but not sure that works as I thought. I'm trying to make each label in the stackview clickable, but after click I will only need getting text from the label, so that is why I used one click listener for all views.
Applying a transform to a view (button, label, view, etc) changes the visual appearance, not the structure.
Because you're working with rotated views, you need to implement hit-testing.
Quick example:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// convert the point to the labels stack view coordinate space
let pt = labelsStack.convert(point, from: self)
// loop through arranged subviews
for i in 0..<labelsStack.arrangedSubviews.count {
let v = labelsStack.arrangedSubviews[i]
// if converted point is inside subview
if v.frame.contains(pt) {
return v
}
}
return super.hitTest(point, with: event)
}
Assuming you're still working with the MyCustomView class and layout from your previous questions, we'll build on that with a few changes for layout, and to allow tapping the labels.
Complete example:
class Step5VC: UIViewController {
// create the custom "left-side" view
let myView = MyCustomView()
// create the "main" stack view
let mainStackView = UIStackView()
// create the "bottom labels" stack view
let bottomLabelsStack = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
guard let img = UIImage(named: "pro1") else {
fatalError("Need an image!")
}
// create the image view
let imgView = UIImageView()
imgView.contentMode = .scaleToFill
imgView.image = img
mainStackView.axis = .horizontal
bottomLabelsStack.axis = .horizontal
bottomLabelsStack.distribution = .fillEqually
// add views to the main stack view
mainStackView.addArrangedSubview(myView)
mainStackView.addArrangedSubview(imgView)
// add main stack view and bottom labels stack view to view
mainStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStackView)
bottomLabelsStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomLabelsStack)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain Top/Leading/Trailing
mainStackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
mainStackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
//mainStackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// we want the image view to be 270 x 270
imgView.widthAnchor.constraint(equalToConstant: 270.0),
imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor),
// constrain the bottom lables to the bottom of the main stack view
// same width as the image view
// aligned trailing
bottomLabelsStack.topAnchor.constraint(equalTo: mainStackView.bottomAnchor),
bottomLabelsStack.trailingAnchor.constraint(equalTo: mainStackView.trailingAnchor),
bottomLabelsStack.widthAnchor.constraint(equalTo: imgView.widthAnchor),
])
// setup the left-side custom view
myView.titleText = "Gefährdung"
let titles: [String] = [
"keine / gering", "mittlere", "erhöhte", "hohe",
]
let colors: [UIColor] = [
UIColor(red: 0.863, green: 0.894, blue: 0.527, alpha: 1.0),
UIColor(red: 0.942, green: 0.956, blue: 0.767, alpha: 1.0),
UIColor(red: 0.728, green: 0.828, blue: 0.838, alpha: 1.0),
UIColor(red: 0.499, green: 0.706, blue: 0.739, alpha: 1.0),
]
for (c, t) in zip(colors, titles) {
// because we'll be using hitTest in our Custom View
// we don't need to set .isUserInteractionEnabled = true
// create a "color label"
let cl = colorLabel(withColor: c, title: t, titleColor: .black)
// we're limiting the height to 270, so
// let's use a smaller font for the left-side labels
cl.font = .systemFont(ofSize: 12.0, weight: .light)
// create a tap recognizer
let t = UITapGestureRecognizer(target: self, action: #selector(didTapRotatedLeftLabel(_:)))
// add the recognizer to the label
cl.addGestureRecognizer(t)
// add the label to the custom myView
myView.addLabel(cl)
}
// rotate the left-side custom view 90-degrees counter-clockwise
myView.rotateTo(-.pi * 0.5)
// setup the bottom labels
let colorDictionary = [
"Red":UIColor.systemRed,
"Green":UIColor.systemGreen,
"Blue":UIColor.systemBlue,
]
for (myKey,myValue) in colorDictionary {
// bottom labels are not rotated, so we can add tap gesture recognizer directly
// create a "color label"
let cl = colorLabel(withColor: myValue, title: myKey, titleColor: .white)
// let's use a smaller, bold font for the left-side labels
cl.font = .systemFont(ofSize: 12.0, weight: .bold)
// by default, .isUserInteractionEnabled is False for UILabel
// so we must set .isUserInteractionEnabled = true
cl.isUserInteractionEnabled = true
// create a tap recognizer
let t = UITapGestureRecognizer(target: self, action: #selector(didTapBottomLabel(_:)))
// add the recognizer to the label
cl.addGestureRecognizer(t)
bottomLabelsStack.addArrangedSubview(cl)
}
}
#objc func didTapRotatedLeftLabel (_ sender: UITapGestureRecognizer) {
if let v = sender.view as? UILabel {
let title = v.text ?? "label with no text"
print("Tapped Label in Rotated Custom View:", title)
// do something based on the tapped label/view
}
}
#objc func didTapBottomLabel (_ sender: UITapGestureRecognizer) {
if let v = sender.view as? UILabel {
let title = v.text ?? "label with no text"
print("Tapped Bottom Label:", title)
// do something based on the tapped label/view
}
}
func colorLabel(withColor color:UIColor, title:String, titleColor:UIColor) -> UILabel {
let newLabel = PaddedLabel()
newLabel.padding = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)
newLabel.backgroundColor = color
newLabel.text = title
newLabel.textAlignment = .center
newLabel.textColor = titleColor
newLabel.setContentHuggingPriority(.required, for: .vertical)
return newLabel
}
}
class MyCustomView: UIView {
public var titleText: String = "" {
didSet { titleLabel.text = titleText }
}
public func addLabel(_ v: UIView) {
labelsStack.addArrangedSubview(v)
}
public func rotateTo(_ d: Double) {
// get the container view (in this case, it's the outer stack view)
if let v = subviews.first {
// set the rotation transform
if d == 0 {
self.transform = .identity
} else {
self.transform = CGAffineTransform(rotationAngle: d)
}
// remove the container view
v.removeFromSuperview()
// tell it to layout itself
v.setNeedsLayout()
v.layoutIfNeeded()
// get the frame of the container view
// apply the same transform as self
let r = v.frame.applying(self.transform)
wC.isActive = false
hC.isActive = false
// add it back
addSubview(v)
// set self's width and height anchors
// to the width and height of the container
wC = self.widthAnchor.constraint(equalToConstant: r.width)
hC = self.heightAnchor.constraint(equalToConstant: r.height)
guard let sv = v.superview else {
fatalError("no superview")
}
// apply the new constraints
NSLayoutConstraint.activate([
v.centerXAnchor.constraint(equalTo: self.centerXAnchor),
v.centerYAnchor.constraint(equalTo: self.centerYAnchor),
wC,
outerStack.widthAnchor.constraint(equalTo: sv.heightAnchor),
])
}
}
// our subviews
private let outerStack = UIStackView()
private let titleLabel = UILabel()
private let labelsStack = UIStackView()
private var wC: NSLayoutConstraint!
private var hC: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// stack views and label properties
outerStack.axis = .vertical
outerStack.distribution = .fillEqually
labelsStack.axis = .horizontal
// let's use .fillProportionally to help fit the labels
labelsStack.distribution = .fillProportionally
titleLabel.textAlignment = .center
titleLabel.backgroundColor = .lightGray
titleLabel.textColor = .white
// add title label and labels stack to outer stack
outerStack.addArrangedSubview(titleLabel)
outerStack.addArrangedSubview(labelsStack)
outerStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(outerStack)
wC = self.widthAnchor.constraint(equalTo: outerStack.widthAnchor)
hC = self.heightAnchor.constraint(equalTo: outerStack.heightAnchor)
NSLayoutConstraint.activate([
outerStack.centerXAnchor.constraint(equalTo: self.centerXAnchor),
outerStack.centerYAnchor.constraint(equalTo: self.centerYAnchor),
wC, hC,
])
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// convert the point to the labels stack view coordinate space
let pt = labelsStack.convert(point, from: self)
// loop through arranged subviews
for i in 0..<labelsStack.arrangedSubviews.count {
let v = labelsStack.arrangedSubviews[i]
// if converted point is inside subview
if v.frame.contains(pt) {
return v
}
}
return super.hitTest(point, with: event)
}
}
class PaddedLabel: UILabel {
var padding: UIEdgeInsets = .zero
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: padding))
}
override var intrinsicContentSize : CGSize {
let sz = super.intrinsicContentSize
return CGSize(width: sz.width + padding.left + padding.right, height: sz.height + padding.top + padding.bottom)
}
}
The problem is with the the stackView's height. Once the label is rotated, the stackview's height is same as before and the tap gestures will only work within stackview's bounds.
I have checked it by changing the height of the stackview at the transform and observed tap gestures are working fine with the rotated label but with the part of it inside the stackview.
Now the problem is that you have to keep the bounds of the label inside the stackview either by changing it axis(again a new problem as need to handle the layout with it) or you have to handle it without the stackview.
You can check the observation by clicking the part of rotated label inside stackview and outside stackview.
Code to check it:
class ViewController: UIViewController {
var centerLabel = UILabel()
let mainStackView = UIStackView()
var stackViewHeightCons:NSLayoutConstraint?
var stackViewTopsCons:NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
mainStackView.axis = .horizontal
mainStackView.alignment = .top
mainStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStackView)
mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
stackViewTopsCons = mainStackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 300)
stackViewTopsCons?.isActive = true
stackViewHeightCons = mainStackView.heightAnchor.constraint(equalToConstant: 30)
stackViewHeightCons?.isActive = true
centerLabel.textAlignment = .center
centerLabel.text = "Let's rotate this label"
centerLabel.backgroundColor = .green
centerLabel.tag = 11
setTapListener(centerLabel)
mainStackView.addArrangedSubview(centerLabel)
// outline the stack view so we can see its frame
mainStackView.layer.borderColor = UIColor.red.cgColor
mainStackView.layer.borderWidth = 1
}
public func setTapListener(_ label: UILabel){
let tapGesture: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureMethod(_:)))
tapGesture.numberOfTapsRequired = 1
tapGesture.numberOfTouchesRequired = 1
label.isUserInteractionEnabled = true
label.addGestureRecognizer(tapGesture)
}
#objc func tapGestureMethod(_ gesture: UITapGestureRecognizer) {
print(gesture.view?.tag ?? 0)
var yCor:CGFloat = 300
if centerLabel.transform == .identity {
centerLabel.transform = CGAffineTransform(rotationAngle: -CGFloat.pi / 2)
yCor = mainStackView.frame.origin.y - (centerLabel.frame.size.height/2)
} else {
centerLabel.transform = .identity
}
updateStackViewHeight(topCons: yCor)
}
private func updateStackViewHeight(topCons:CGFloat) {
stackViewTopsCons?.constant = topCons
stackViewHeightCons?.constant = centerLabel.frame.size.height
}
}
Sorry. My assumption was incorrect.
Why are you decided to use Label instead of UIButton (with transparence background color and border line)?
Also you can use UITableView instead of stack & labels
Maybe this documentation will help too (it is written that usually in one view better to keep one gesture recognizer): https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/coordinating_multiple_gesture_recognizers
I have a view controller with the below UI layout.
There is a header view at the top with 3 labels, a footer view with 2 buttons at the bottom and an uitableview inbetween header view and footer view. The uitableview is dynamically loaded and on average has about 6 tableview cells. One of the buttons in the footer view is take screenshot button where i need to take the screenshot of full tableview. In small devices like iPhone 6, the height of the table is obviously small as it occupies the space between header view and footer view. So only 4 cells are visible to the user and as the user scrolls others cells are loaded into view. If the user taps take screen shot button without scrolling the table view, the last 2 cells are not captured in the screenshot. The current implementation tried to negate this by changing table view frame to table view content size before capturing screenshot and resetting frame after taking screenshot, but this approach is not working starting iOS 13 as the table view content size returns incorrect values.
Current UI layout implementation
Our first solution is to embed the tableview inside the scrollview and have the tableview's scroll disabled. By this way the tableview will be forced to render all cells at once. We used the below custom table view class to override intrinsicContentSize to make the tableview adjust itself to correct height based on it contents
class CMDynamicHeightAdjustedTableView: UITableView {
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
return self.contentSize
}
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
}
Proposed UI implementation
But we are little worried about how overriding intrinsicContentSize could affect performance and other apple's internal implementations
So our second solution is to set a default initial height constraint for tableview and observe the tableview's content size keypath and update the tableview height constraint accordingly. But the content size observer gets called atleast 12-14 times before the screen elements are visible to the user.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.confirmationTableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
if object is UITableView {
if let newvalue = change?[.newKey], let newSize = newvalue as? CGSize {
self.confirmationTableViewHeightConstraint.constant = newSize.height
}
}
}
}
Will the second approach impact performance too?
What is the better approach of the two?
Is there any alternate solution?
I am not sure, but if I understood correctly when you screenshot the TableView the last 2 cells are not loaded because of the tableview being between the Header and Footer. Here are two options I would consider:
Option 1
Try to make the TableView frame start from the Header and have the height of the Unscreen.main.bounds.height - the Header view frame. This would mean that the tableView will expand toward the end of the screen. Then add the Footer over the tableView in the desired relation.
Option 2
Try before screenshooting, to reloadRows at two level below the current Level. You can get the current indexPath of the UITableView, when the TableView reloads it from its delegate, store it somewhere always the last indexPath used, and when screenshot reload the two below.
You can "temporarily" change the height of your table view, force it to update, render it to a UIImage, and then set the height back.
Assuming you have your "Header" view constrained to the top, your "Footer" view constrained to the bottom, and your table view constrained between them...
Add a class var/property for the table view's bottom constraint:
var tableBottomConstraint: NSLayoutConstraint!
then set that constraint:
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
When you want to "capture" the table:
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
Here is a complete example you can run to test it:
class SimpleCell: UITableViewCell {
let theLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.backgroundColor = .yellow
return v
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
theLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: g.topAnchor),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
])
}
}
class TableCapVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
let tableView = UITableView()
// let's use 12 rows, each with 1, 2, 3 or 4 lines of text
// so it will definitely be too many rows to see on the screen
let numRows: Int = 12
var tableBottomConstraint: NSLayoutConstraint!
// we'll use this to display that captured table view image
let resultHolder = UIView()
let resultImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let headerView = myHeaderView()
let footerView = myFooterView()
[headerView, tableView, footerView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
// we will use this to change the bottom constraint of the table view
// when we want to capture it
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
NSLayoutConstraint.activate([
// constrain "header" view at the top
headerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
headerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
headerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
// constrain "fotter" view at the bottom
footerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
footerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
footerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain table view between header and footer views
tableView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 0.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
tableBottomConstraint,
])
tableView.register(SimpleCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
// we'll add a UIImageView (in a "holder" view) on top of the table
// then show/hide it to see the results of
// the table capture
resultImageView.backgroundColor = .gray
resultImageView.layer.borderColor = UIColor.cyan.cgColor
resultImageView.layer.borderWidth = 1
resultImageView.layer.cornerRadius = 16.0
resultImageView.layer.shadowColor = UIColor.black.cgColor
resultImageView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
resultImageView.layer.shadowRadius = 8
resultImageView.layer.shadowOpacity = 0.9
resultImageView.contentMode = .scaleAspectFit
resultHolder.alpha = 0.0
resultHolder.translatesAutoresizingMaskIntoConstraints = false
resultImageView.translatesAutoresizingMaskIntoConstraints = false
resultHolder.addSubview(resultImageView)
view.addSubview(resultHolder)
NSLayoutConstraint.activate([
// cover everything with the clear "holder" view
resultHolder.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
resultHolder.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
resultHolder.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
resultHolder.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
resultImageView.topAnchor.constraint(equalTo: resultHolder.topAnchor, constant: 20.0),
resultImageView.leadingAnchor.constraint(equalTo: resultHolder.leadingAnchor, constant: 20.0),
resultImageView.trailingAnchor.constraint(equalTo: resultHolder.trailingAnchor, constant: -20.0),
resultImageView.bottomAnchor.constraint(equalTo: resultHolder.bottomAnchor, constant: -20.0),
])
// tap image view / holder view when showing to hide it
let t = UITapGestureRecognizer(target: self, action: #selector(hideImage))
resultHolder.addGestureRecognizer(t)
}
func myHeaderView() -> UIView {
let v = UIView()
v.backgroundColor = .systemBlue
let sv = UIStackView()
sv.axis = .vertical
sv.spacing = 4
let strs: [String] = [
"\"Header\" and \"Footer\" views",
"are separate views - they are not",
".tableHeaderView / .tableFooterView",
]
strs.forEach { str in
let label = UILabel()
label.text = str
label.textAlignment = .center
label.font = .systemFont(ofSize: 13.0, weight: .regular)
label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
sv.addArrangedSubview(label)
}
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func myFooterView() -> UIView {
let v = UIView()
v.backgroundColor = .systemPink
let sv = UIStackView()
sv.axis = .horizontal
sv.spacing = 12
sv.distribution = .fillEqually
let btn1: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Capture Table"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn1Action(_:)), for: .touchUpInside)
return b
}()
let btn2: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Another Button"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn2Action(_:)), for: .touchUpInside)
return b
}()
sv.addArrangedSubview(btn1)
sv.addArrangedSubview(btn2)
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numRows
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! SimpleCell
let nLines = indexPath.row % 4
var s: String = "Row: \(indexPath.row)"
for i in 0..<nLines {
s += "\nLine \(i+2)"
}
c.theLabel.text = s
return c
}
#objc func btn1Action(_ sender: UIButton) {
let img = captureTableView()
print("TableView Image Captured - size:", img.size)
// do something with the tableView capture
// maybe save it to documents folder?
// for this example, we will show it
resultImageView.image = img
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 1.0
})
}
#objc func hideImage() {
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 0.0
})
}
#objc func btn2Action(_ sender: UIButton) {
print("Another Button Tapped")
}
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
}
We give the table 12 rows, each with 1, 2, 3 or 4 lines of text so it will definitely be too many rows to see on the screen. Tapping on the "Capture Table" button will capture the table to a UIImage and then display that image. Tap on the image to dismiss it:
I have a simple UICollectionView in a view controller. I am animating the top constraint of the collection view via a button. On the FIRST button tap, the collection view cells are animating quite oddly. After subsequent taps the animation is smooth.
Method to animate:
#objc func animateAction() {
UIView.animate(withDuration: 1) {
self.animateUp.toggle()
self.topConstraint.constant = self.animateUp ? 100 : self.view.bounds.height - 100
self.view.layoutIfNeeded()
}
}
Edit: What actually needs to be built:
It looks like you are animating the Top Constraint of your collection view, which changes its Height.
Collection view's only render cells when needed.
So, at the start only one (or two) cells are created. Then as you change the Height, new cells are created and added. So, you see an "odd animation."
What you want to do is NOT set a bottom constraint for your collection view. Instead, set its Height constraint, and then change the Top constraint to "slide" it up and down:
I'm assuming you're using UICollectionViewCompositionalLayout.list with appearance: .insetGrouped ...
Here is a complete example to get that result:
struct MyCVData: Hashable {
var name: String
}
class AnimCVViewController: UIViewController {
var myCollectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, MyCVData>!
var cvDataList: [MyCVData] = []
enum Section {
case main
}
var snapshot: NSDiffableDataSourceSnapshot<Section, MyCVData>!
var topConstraint: NSLayoutConstraint!
// when collection view is "Up" we want its
// Top to be 100-points from the Top of the view (safe area)
var topPosition: CGFloat = 100
// when collection view is "Down" we want its
// Top to be 80-points from the Bottom of the view (safe area)
var bottomPosition: CGFloat = 80
override func viewDidLoad() {
super.viewDidLoad()
// so we have a title if we're in a navigation controller
self.navigationController?.setNavigationBarHidden(true, animated: false)
view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
configureCollectionView()
buildData()
// create an Animate button
let btn = UIButton()
btn.backgroundColor = .yellow
btn.setTitle("Animate", for: [])
btn.setTitleColor(.black, for: .normal)
btn.setTitleColor(.lightGray, for: .highlighted)
btn.translatesAutoresizingMaskIntoConstraints = false
myCollectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btn)
view.addSubview(myCollectionView)
let g = view.safeAreaLayoutGuide
// start with the collection view "Down"
topConstraint = myCollectionView.topAnchor.constraint(equalTo: g.bottomAnchor, constant: -bottomPosition)
NSLayoutConstraint.activate([
// constrain the button at the Top, 200-pts width, centered horizontally
btn.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
btn.widthAnchor.constraint(equalToConstant: 200.0),
btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// button Height 10-points less than our collection view's Top Position
btn.heightAnchor.constraint(equalToConstant: topPosition - 10.0),
// activate top constraint
topConstraint,
// collection view Height should be the Height of the view (safe area)
// minus the Top Position
myCollectionView.heightAnchor.constraint(equalTo: g.heightAnchor, constant: -topPosition),
// let's use 40-points leading and trailing
myCollectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
myCollectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
])
// add an action for the button
btn.addTarget(self, action: #selector(animateAction), for: .touchUpInside)
}
#objc func animateAction() {
// if the topConstraint constant is -bottomPosition, that means it is "Down"
// so, if it's "Down"
// animate it so its Top is its own Height from the Bottom
// otherwise
// animate it so its Top is at bottomPosition
topConstraint.constant = topConstraint.constant == -bottomPosition ? -myCollectionView.frame.height : -bottomPosition
UIView.animate(withDuration: 1.0, animations: {
self.view.layoutIfNeeded()
})
}
func configureCollectionView() {
var layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
layoutConfig.backgroundColor = .red
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
myCollectionView = UICollectionView(frame: .zero, collectionViewLayout: listLayout)
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, MyCVData> { (cell, indexPath, item) in
var content = UIListContentConfiguration.cell()
content.text = item.name
content.textProperties.font.withSize(8.0)
content.textProperties.font = UIFont.preferredFont(forTextStyle: .body)
content.textProperties.adjustsFontSizeToFitWidth = false
cell.contentConfiguration = content
}
dataSource = UICollectionViewDiffableDataSource<Section, MyCVData>(collectionView: myCollectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: MyCVData) -> UICollectionViewCell? in
// Dequeue reusable cell using cell registration (Reuse identifier no longer needed)
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
for: indexPath,
item: identifier)
return cell
}
}
func buildData() {
// create 20 data items ("Cell: 1" / "Cell: 2" / "Cell: 3" / etc...)
for i in 0..<20 {
let d = MyCVData(name: "Cell: \(i)")
cvDataList.append(d)
}
// Create a snapshot that define the current state of data source's data
self.snapshot = NSDiffableDataSourceSnapshot<Section, MyCVData>()
self.snapshot.appendSections([.main])
self.snapshot.appendItems(cvDataList, toSection: .main)
// Display data in the collection view by applying the snapshot to data source
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
}
I am trying to implement swipe to delete feature with two options in tableview, one is to delete and another one is to Update.The things I want is these options should be vertical rather than horizontal.I have checked so many question but nothing find.
Thanks in advance for support.
.
As I mentioned in the comments, here is one approach:
add your buttons to the cell
add a "container" view to the cell
constrain the container view so it overlays / covers the buttons
add a Pan gesture recognizer to the container view so you can drag it left / right
as you drag it left, it will "reveal" the buttons underneath
You lose all of the built-in swipe functionality, but this is one approach that might give you the design you're going for.
First, an example of creating a "drag view":
class DragTestViewController: UIViewController {
let backgroundView = UIView()
let containerView = UIView()
// leading and trailing constraints for the drag view
private var leadingConstraint: NSLayoutConstraint!
private var trailingConstraint: NSLayoutConstraint!
private let origLeading = CGFloat(60.0)
private let origTrailing = CGFloat(-60.0)
private var currentLeading = CGFloat(60.0)
private var currentTrailing = CGFloat(-60.0)
override func viewDidLoad() {
super.viewDidLoad()
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.backgroundColor = .cyan
backgroundView.clipsToBounds = true
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.backgroundColor = .red
// add a label to the container view
let exampleLabel = UILabel()
exampleLabel.translatesAutoresizingMaskIntoConstraints = false
exampleLabel.text = "Drag Me"
exampleLabel.textColor = .yellow
containerView.addSubview(exampleLabel)
backgroundView.addSubview(containerView)
view.addSubview(backgroundView)
leadingConstraint = containerView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor, constant: origLeading)
trailingConstraint = containerView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: origTrailing)
NSLayoutConstraint.activate([
// constrain backgroundView top to top + 80
backgroundView.topAnchor.constraint(equalTo: view.topAnchor, constant: 80.0),
// constrain backgroundView leading / trailing to leading / trailing with 40-pt "padding"
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40.0),
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40.0),
// constrain height to 100
backgroundView.heightAnchor.constraint(equalToConstant: 100.0),
// constrain containerView top / bottom to backgroundView top / bottom with 8-pt padding
containerView.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 8.0),
containerView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -8.0),
// activate leading / trailing constraints
leadingConstraint,
trailingConstraint,
// constrain the example label centered in the container view
exampleLabel.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
exampleLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
// pan gesture recognizer
let p = UIPanGestureRecognizer(target: self, action: #selector(self.drag(_:)))
containerView.addGestureRecognizer(p)
}
#objc func drag(_ g: UIPanGestureRecognizer) -> Void {
// when we get a Pan on the containerView - a "drag" ...
guard let sv = g.view?.superview else {
return
}
let translation = g.translation(in: sv)
switch g.state {
case .began:
// update current vars
currentLeading = leadingConstraint.constant
currentTrailing = trailingConstraint.constant
case .changed:
// only track left-right dragging
leadingConstraint.constant = currentLeading + translation.x
trailingConstraint.constant = currentTrailing + translation.x
default:
break
}
}
}
That code will produce this:
A red view with a centered label, inside a cyan view. You can drag the red "container" view left and right.
Add a view controller to a new project and assign its Custom Class to DragTestViewController from the above code. There are no #IBOutlet or #IBAction connections, so you should be able to run it as-is. See if you can drag the red view.
Using that as a starting point, we can get this:
with this code:
// simple rounded-corner shadowed view
class ShadowRoundedView: UIView {
let shadowLayer: CAShapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
self.layer.addSublayer(shadowLayer)
clipsToBounds = false
backgroundColor = .clear
shadowLayer.fillColor = UIColor.white.cgColor
shadowLayer.shadowColor = UIColor.black.cgColor
shadowLayer.shadowOffset = CGSize(width: 0.0, height: 1.0)
shadowLayer.shadowRadius = 4.0
shadowLayer.shadowOpacity = 0.6
shadowLayer.shouldRasterize = true
shadowLayer.rasterizationScale = UIScreen.main.scale
}
override func layoutSubviews() {
super.layoutSubviews()
let pth = UIBezierPath(roundedRect: bounds, cornerRadius: 16.0)
shadowLayer.path = pth.cgPath
}
}
// simple rounded button
class RoundedButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.size.height * 0.5
}
}
class DragRevealCell: UITableViewCell {
// callback closure for button taps
var callback: ((Int) -> ())?
// this will hold the "visible" labels, and will initially cover the buttons
let containerView: ShadowRoundedView = {
let v = ShadowRoundedView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// this will hold the buttons
let buttonsView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.clipsToBounds = true
return v
}()
// a "delete" button
let deleteButton: RoundedButton = {
let v = RoundedButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.setTitle("Delete", for: [])
v.setTitleColor(.blue, for: [])
v.setTitleColor(.lightGray, for: .highlighted)
v.backgroundColor = .white
return v
}()
// an "update" button
let updateButton: RoundedButton = {
let v = RoundedButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.setTitle("Update", for: [])
v.setTitleColor(.white, for: [])
v.setTitleColor(.lightGray, for: .highlighted)
v.backgroundColor = .blue
return v
}()
// single label for this example cell
let myLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.numberOfLines = 0
return v
}()
// leading and trailing constraints for the container view
private var leadingConstraint: NSLayoutConstraint!
private var trailingConstraint: NSLayoutConstraint!
private let origLeading = CGFloat(8.0)
private let origTrailing = CGFloat(-8.0)
private var currentLeading = CGFloat(0.0)
private var currentTrailing = CGFloat(0.0)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// cell background color
backgroundColor = UIColor(white: 0.95, alpha: 1.0)
// add buttons to buttons container view
buttonsView.addSubview(deleteButton)
buttonsView.addSubview(updateButton)
// add label to container view -- this is where you would add all your labels, stack views, image views, etc.
containerView.addSubview(myLabel)
// add buttons view first
addSubview(buttonsView)
// add container view second - this will "overlay" it on top of the buttons view
addSubview(containerView)
// containerView leading / trailing constraints - these will be updated as we drag
leadingConstraint = containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: origLeading)
trailingConstraint = containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: origTrailing)
// needed to avoid layout warnings
let bottomConstraint = containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0)
bottomConstraint.priority = UILayoutPriority(rawValue: 999)
NSLayoutConstraint.activate([
containerView.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
leadingConstraint,
trailingConstraint,
bottomConstraint,
myLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0),
myLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20.0),
myLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20.0),
myLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -8.0),
myLabel.heightAnchor.constraint(equalToConstant: 120.0),
buttonsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
buttonsView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
deleteButton.topAnchor.constraint(equalTo: buttonsView.topAnchor, constant: 0.0),
deleteButton.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor, constant: 8.0),
deleteButton.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor, constant: -8.0),
updateButton.bottomAnchor.constraint(equalTo: buttonsView.bottomAnchor, constant: 0.0),
updateButton.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor, constant: 8.0),
updateButton.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor, constant: -8.0),
updateButton.topAnchor.constraint(equalTo: deleteButton.bottomAnchor, constant: 12.0),
updateButton.heightAnchor.constraint(equalTo: deleteButton.heightAnchor),
updateButton.widthAnchor.constraint(equalTo: deleteButton.widthAnchor),
deleteButton.widthAnchor.constraint(equalToConstant: 120.0),
deleteButton.heightAnchor.constraint(equalToConstant: 40.0),
])
// delete button border
deleteButton.layer.borderColor = UIColor.blue.cgColor
deleteButton.layer.borderWidth = 1.0
// targets for button taps
deleteButton.addTarget(self, action: #selector(self.deleteTapped(_:)), for: .touchUpInside)
updateButton.addTarget(self, action: #selector(self.updateTapped(_:)), for: .touchUpInside)
// pan gesture recognizer
let p = UIPanGestureRecognizer(target: self, action: #selector(self.drag(_:)))
containerView.addGestureRecognizer(p)
}
#objc func drag(_ g: UIPanGestureRecognizer) -> Void {
// when we get a Pan on the container view - a "drag" ...
guard let sv = g.view?.superview else {
return
}
let translation = g.translation(in: sv)
switch g.state {
case .began:
currentLeading = leadingConstraint.constant
currentTrailing = trailingConstraint.constant
case .changed:
// only track left-right dragging
// don't allow drag-to-the-right
if currentLeading + translation.x <= origLeading {
leadingConstraint.constant = currentLeading + translation.x
trailingConstraint.constant = currentTrailing + translation.x
}
default:
// if the drag-left did not fully reveal the buttons, animate the container view back in place
if containerView.frame.maxX > buttonsView.frame.minX {
self.leadingConstraint.constant = self.origLeading
self.trailingConstraint.constant = self.origTrailing
UIView.animate(withDuration: 0.3, animations: {
self.layoutIfNeeded()
}, completion: { _ in
//self.dragX = 0.0
})
}
}
}
#objc func deleteTapped(_ sender: Any?) -> Void {
callback?(0)
}
#objc func updateTapped(_ sender: Any?) -> Void {
callback?(1)
}
}
class DragRevealTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(DragRevealCell.self, forCellReuseIdentifier: "DragRevealCell")
tableView.separatorStyle = .none
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "DragRevealCell", for: indexPath) as! DragRevealCell
c.myLabel.text = "Row \(indexPath.row)" + "\n" + "This is where you would populate the cell's labels, image views, any other UI elements, etc."
c.selectionStyle = .none
c.callback = { value in
if value == 0 {
print("Delete action")
} else {
print("Update action")
}
}
return c
}
}
Add a UITableViewController the project and assign its Custom Class to DragRevealTableViewController from the above code. Again, there are no #IBOutlet or #IBAction connections, so you should be able to run it as-is.
NOTE: This is example code only, and should not be considered "production ready"!!! It is only partially implemented and will likely need quite a bit more work. But, it may give you a good starting point.
UPDATED BELOW!
I have a UI structure with a horizontal scrollView nesting 5 tableViews – each representing a day of the week. I have added a UISwitch to add weekend to the week, so when the user switches it on, two more tableview-subviews are added to the scrollView. So far so good, but the switch change only takes effect, when I relaunch the application. Looks like ViewDidLoad() makes it happen, but nothing else. I added a Bool variable called isWeekOn. Its state is managed from viewDidLoad:
isWeekendOn = UserDefaults.standard.bool(forKey: "switchState")
dayTableViews = fiveOrSevenDayTableViews()
where fiveOrSevenTableViews() is a closure returning the array of tableviews with the proper count and dayTableViews is my local array variable.
lazy var fiveOrSevenDayTableViews: () -> [DayTableView] = {
if self.isWeekendOn == false {
return [self.mondayTableView, self.tuesdayTableview, self.wednesdayTableview, self.thursdayTableView, self.fridayTableView]
} else {
return [self.mondayTableView, self.tuesdayTableview, self.wednesdayTableview, self.thursdayTableView, self.fridayTableView, self.saturdayTableView,self.sundayTableView]
}
}
I added a didSet property observer to isWeekendOn and that also calls setupViews(), where the number of tableviews is also decided by calling fiveOrSevenTableViews closure .
var isWeekendOn: Bool = false {
didSet {
print("LessonVC IsWeekendon: ",isWeekendOn)
dayTableViews = fiveOrSevenDayTableViews()
setupViews()
print("didset daytableviews", fiveOrSevenDayTableViews().count)
}
}
Where my setupViews() looks like:
func setupViews() {
setupScrollView()
let numberOfTableViews = CGFloat(dayTableViews.count)
let stackView = UIStackView(arrangedSubviews: fiveOrSevenDayTableViews())
print("setupViews stacview subviews count", stackView.arrangedSubviews.count)
stackView.axis = .horizontal
stackView.distribution = .fillEqually
scrollView.addSubview(stackView)
setupStackViewConstraints(stackView, numberOfTableViews)
}
And setupScrollView():
private func setupScrollView() {
let numberOfTableViews = CGFloat(dayTableViews.count)
print("setupScrollview dableviews", numberOfTableViews)
scrollView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height:0)
scrollView.contentSize = CGSize(width: view.frame.width * numberOfTableViews, height: 0)
view.addSubview(scrollView)
setupScrollviewConstraints()
}
All the print statements are called properly, so I am wondering, why the changes actually do not take effect real time, and instead working only relaunch.
What I tried:
As #maniponken suggested, i made a function which looks like:
func readdStackView(_ stackView: UIStackView) { stackView.removeFromSuperview()
setupViews() }
than I call this within the isWeekendOn didSet observer. Didn't work out unfortunately.
UPDATE:
Actually when I put anything in my isWeekendon didSet observer, doesn't work! For example changing my navigationBar backgroundColor...etc Everything is reflecting on console though, in the print statements! Those functions also take effect at relaunch only.I have no idea what I am doing wrong.
UPDATE2:
Removing the tables works without problem with a local UIButton! My Problem is the following though: I have a settings view controller, which has a switch for setting 5 or 7 table views. Realtime update does not work with that switch, only with le local button, triggering an #objc func. I still need that settings panel for the user though!
Try this, it's not a stackview but it works for adding (and removing) tableviews to a ViewController.
This method is not using Storyboards
In your viewcontroller, containing the tableview
import Foundation
import UIKit
class SevenTableviews: UIViewController, UITableViewDelegate, UITableViewDataSource {
let tableView1: UITableView = {
let tv = UITableView()
tv.backgroundColor = .white
tv.separatorStyle = .none
return tv
}()
let tableView2: UITableView = {
let tv = UITableView()
tv.backgroundColor = .white
tv.separatorStyle = .none
return tv
}()
let tableSwitch: UISwitch = {
let switchBtn = UISwitch()
switchBtn.addTarget(self, action: #selector(switchTables), for: .touchUpInside)
return switchBtn
}()
var isTableTwoShowing = false
let reuseIdentifier = "DaysCell"
var days = ["monday", "tuesday", "wednesday", "thursday", "friday"]
var weekendDays = ["saturday", "sunday"]
override func viewDidLoad() {
super.viewDidLoad()
setupTableview()
}
func setupTableview() {
tableView1.dataSource = self
tableView1.delegate = self
tableView1.register(DaysTableviewCell.self, forCellReuseIdentifier: reuseIdentifier)
view.addSubview(tableView1)
tableView1.anchor(top: view.safeAreaLayoutGuide.topAnchor, left: view.leftAnchor, bottom: view.centerYAnchor, right: view.rightAnchor)
if isTableTwoShowing == true {
tableView2.dataSource = self
tableView2.delegate = self
tableView2.register(DaysTableviewCell.self, forCellReuseIdentifier: reuseIdentifier)
view.addSubview(tableView2)
tableView2.anchor(top: view.centerYAnchor, left: view.leftAnchor, bottom: view.safeAreaLayoutGuide.bottomAnchor, right: view.rightAnchor)
}
view.addSubview(tableSwitch)
tableSwitch.anchor(bottom: view.safeAreaLayoutGuide.bottomAnchor, right: view.rightAnchor, paddingBottom: 24, paddingRight: 12)
}
#objc func switchTables() {
if tableSwitch.isOn {
isTableTwoShowing = true
setupTableview()
} else {
isTableTwoShowing = false
tableView2.removeFromSuperview()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if tableView == tableView1 {
return days.count
} else if tableView == tableView2 {
return weekendDays.count
} else {
return 0
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as! DaysTableviewCell
if tableView == tableView1 {
cell.dateLabel.text = days[indexPath.row]
return cell
} else {
cell.dateLabel.text = weekendDays[indexPath.row]
return cell
}
}
}
in your tableviewCell-class:
import Foundation
import UIKit
class DaysTableviewCell: UITableViewCell {
let identifier = "DaysCell"
let cellContainer: UIView = {
let view = UIView()
view.backgroundColor = .white
view.backgroundColor = Colors.boxBack
view.setCellShadow()
return view
}()
let dateLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 20)
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
}
func setupViews() {
selectionStyle = .none
addSubview(cellContainer)
cellContainer.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 4, paddingLeft: 8, paddingBottom: 4, paddingRight: 8, height: 35)
cellContainer.addSubview(dateLabel)
dateLabel.anchor(top: cellContainer.topAnchor, left: cellContainer.leftAnchor, bottom: cellContainer.bottomAnchor, right: cellContainer.rightAnchor, paddingTop: 4, paddingLeft: 8, paddingBottom: 4, paddingRight: 8)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I am using the same cell class for both tableviews but you can decide yourself how you want to do this.
Also, my constraints are set with an extension i found from a tutorial once:
extension UIView {
func anchor(top: NSLayoutYAxisAnchor? = nil, left: NSLayoutXAxisAnchor? = nil, bottom: NSLayoutYAxisAnchor? = nil, right: NSLayoutXAxisAnchor? = nil, paddingTop: CGFloat? = 0, paddingLeft: CGFloat? = 0, paddingBottom: CGFloat? = 0, paddingRight: CGFloat? = 0, width: CGFloat? = nil, height: CGFloat? = nil) {
translatesAutoresizingMaskIntoConstraints = false
if let top = top {
topAnchor.constraint(equalTo: top, constant: paddingTop!).isActive = true
}
if let left = left {
leftAnchor.constraint(equalTo: left, constant: paddingLeft!).isActive = true
}
if let bottom = bottom {
if let paddingBottom = paddingBottom {
bottomAnchor.constraint(equalTo: bottom, constant: -paddingBottom).isActive = true
}
}
if let right = right {
if let paddingRight = paddingRight {
rightAnchor.constraint(equalTo: right, constant: -paddingRight).isActive = true
}
}
if let width = width {
widthAnchor.constraint(equalToConstant: width).isActive = true
}
if let height = height {
heightAnchor.constraint(equalToConstant: height).isActive = true
}
}
}
Hope this helps
Couple notes:
You don't need to re-create / re-add your stack view ever time the switch gets changed. Add it in viewDidLoad() and then add / remove the "DayTableViews"
Use constraints for your stack view inside your scroll view, instead of calculating .contentSize.
Probably want to use an array of your "Day Tables" rather than having individual mondayTableView, tuesdayTableView, etc... vars.
Here's an example you can work from. I used a simple UIView with a centered label as a simulated "DayTableView" - should be pretty clear. Everything is via code - no #IBOutlet or #IBAction - so to test this, create a new project, add this code, and assign the startup view controller to AddToScrollViewController:
//
// AddToScrollViewController.swift
//
// Created by Don Mag on 11/15/19.
//
import UIKit
class DayTableView: UIView {
// simple UIView with a centered label
// this is just simulatig a UITableView
let theLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.textAlignment = .center
v.backgroundColor = .yellow
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
addSubview(theLabel)
NSLayoutConstraint.activate([
theLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
theLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
}
class AddToScrollViewController: UIViewController {
let theSwitch: UISwitch = {
let v = UISwitch()
return v
}()
let scrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .orange
return v
}()
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fillEqually
v.spacing = 16
return v
}()
let mondayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Monday"
return v
}()
let tuesdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Tuesday"
return v
}()
let wednesdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Wednesday"
return v
}()
let thursdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Thursday"
return v
}()
let fridayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Friday"
return v
}()
let saturdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Saturday"
return v
}()
let sundayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Sunday"
return v
}()
var dayTableViews: [DayTableView] = [DayTableView]()
lazy var fiveOrSevenDayTableViews: () -> [DayTableView] = {
if self.isWeekendOn == false {
return [self.mondayTableView, self.tuesdayTableView, self.wednesdayTableView, self.thursdayTableView, self.fridayTableView]
} else {
return [self.mondayTableView, self.tuesdayTableView, self.wednesdayTableView, self.thursdayTableView, self.fridayTableView, self.saturdayTableView,self.sundayTableView]
}
}
var isWeekendOn: Bool = false {
didSet {
print("LessonVC IsWeekendon: ",isWeekendOn)
dayTableViews = fiveOrSevenDayTableViews()
setupViews()
print("didset daytableviews", fiveOrSevenDayTableViews().count)
}
}
override func viewDidLoad() {
super.viewDidLoad()
// for each of these views...
[theSwitch, scrollView, stackView].forEach {
// we're going to use auto-layout
$0.translatesAutoresizingMaskIntoConstraints = false
}
// for each of these views...
[mondayTableView, tuesdayTableView, wednesdayTableView, thursdayTableView, fridayTableView, saturdayTableView, sundayTableView].forEach {
// we're going to use auto-layout
$0.translatesAutoresizingMaskIntoConstraints = false
// constrain widths to 160 (change to desired table view widths)
$0.widthAnchor.constraint(equalToConstant: 160.0).isActive = true
// give them a background color so we can see them
$0.backgroundColor = .systemBlue
}
// add the (empty) stack view to the scroll view
scrollView.addSubview(stackView)
// add the switch to the view
view.addSubview(theSwitch)
// add the scroll view to the view
view.addSubview(scrollView)
// use safe area for view elements
let g = view.safeAreaLayoutGuide
// we need to constrain the scroll view contents (the stack view, in this case)
// to the contentLayoutGuide so auto-layout can handle the content sizing
let sg = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
// put switch in top-left corner
theSwitch.topAnchor.constraint(equalTo: g.topAnchor, constant: 12.0),
theSwitch.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 12.0),
// constrain scroll view 12-pts below the switch
// and leading / trailing / bottom at Zero
scrollView.topAnchor.constraint(equalTo: theSwitch.bottomAnchor, constant: 12.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
// constrain the stack view to the scroll view's contentLayoutGuide
// with 8-pts padding on each side (easier to see the framing)
stackView.topAnchor.constraint(equalTo: sg.topAnchor, constant: 8.0),
stackView.bottomAnchor.constraint(equalTo: sg.bottomAnchor, constant: -8.0),
stackView.leadingAnchor.constraint(equalTo: sg.leadingAnchor, constant: 8.0),
stackView.trailingAnchor.constraint(equalTo: sg.trailingAnchor, constant: -8.0),
// constrain height of stack view to height of scroll view frame,
// minus 16-pts (for 8-pt padding)
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor, constant: -16),
])
// add a target for the switch
theSwitch.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
// set based on saved state in UserDefaults
isWeekendOn = UserDefaults.standard.bool(forKey: "switchState")
}
#objc func switchChanged(_ sender: Any) {
// switch was tapped (toggled on/off)
if let v = sender as? UISwitch {
// update state in UserDefaults
UserDefaults.standard.set(v.isOn, forKey: "switchState")
// update the UI
isWeekendOn = v.isOn
}
}
func setupViews() {
// first, remove any existing table views
stackView.arrangedSubviews.forEach {
$0.removeFromSuperview()
}
// get the array of 5 or 7 table views
let a = fiveOrSevenDayTableViews()
// add the table views to the stack view
a.forEach {
stackView.addArrangedSubview($0)
}
print("setupViews stacview subviews count", stackView.arrangedSubviews.count)
}
}
Scrolled to the right with the "weekend switch" off:
Scrolled to the right immediately after turning the "weekend switch" on:
Edit
Here is a slightly different (bit more efficient) approach. Instead of adding / removing table views, simply show / hide the Saturday and Sunday tables. The stack view will automatically handle the scroll view's content size.
Full updated example:
//
// AddToScrollViewController.swift
//
// Created by Don Mag on 11/15/19.
//
import UIKit
class DayTableView: UIView {
// simple UIView with a centered label
// this is just simulatig a UITableView
let theLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.textAlignment = .center
v.backgroundColor = .yellow
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
addSubview(theLabel)
NSLayoutConstraint.activate([
theLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
theLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
}
class AddToScrollViewController: UIViewController {
let theSwitch: UISwitch = {
let v = UISwitch()
return v
}()
let scrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .orange
return v
}()
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fillEqually
v.spacing = 16
return v
}()
let mondayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Monday"
return v
}()
let tuesdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Tuesday"
return v
}()
let wednesdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Wednesday"
return v
}()
let thursdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Thursday"
return v
}()
let fridayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Friday"
return v
}()
let saturdayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Saturday"
return v
}()
let sundayTableView: DayTableView = {
let v = DayTableView()
v.theLabel.text = "Sunday"
return v
}()
var isWeekendOn: Bool = false {
didSet {
print("LessonVC IsWeekendon: ",isWeekendOn)
setupViews()
}
}
override func viewDidLoad() {
super.viewDidLoad()
// for each of these views...
[theSwitch, scrollView, stackView].forEach {
// we're going to use auto-layout
$0.translatesAutoresizingMaskIntoConstraints = false
}
// for each of these views...
[mondayTableView, tuesdayTableView, wednesdayTableView, thursdayTableView, fridayTableView, saturdayTableView, sundayTableView].forEach {
// we're going to use auto-layout
$0.translatesAutoresizingMaskIntoConstraints = false
// constrain widths to 160 (change to desired table view widths)
$0.widthAnchor.constraint(equalToConstant: 160.0).isActive = true
// give them a background color so we can see them
$0.backgroundColor = .systemBlue
// add them to the stack view
stackView.addArrangedSubview($0)
}
// add the stack view to the scroll view
scrollView.addSubview(stackView)
// add the switch to the view
view.addSubview(theSwitch)
// add the scroll view to the view
view.addSubview(scrollView)
// use safe area for view elements
let g = view.safeAreaLayoutGuide
// we need to constrain the scroll view contents (the stack view, in this case)
// to the contentLayoutGuide so auto-layout can handle the content sizing
let sg = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
// put switch in top-left corner
theSwitch.topAnchor.constraint(equalTo: g.topAnchor, constant: 12.0),
theSwitch.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 12.0),
// constrain scroll view 12-pts below the switch
// and leading / trailing / bottom at Zero
scrollView.topAnchor.constraint(equalTo: theSwitch.bottomAnchor, constant: 12.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
// constrain the stack view to the scroll view's contentLayoutGuide
// with 8-pts padding on each side (easier to see the framing)
stackView.topAnchor.constraint(equalTo: sg.topAnchor, constant: 8.0),
stackView.bottomAnchor.constraint(equalTo: sg.bottomAnchor, constant: -8.0),
stackView.leadingAnchor.constraint(equalTo: sg.leadingAnchor, constant: 8.0),
stackView.trailingAnchor.constraint(equalTo: sg.trailingAnchor, constant: -8.0),
// constrain height of stack view to height of scroll view frame,
// minus 16-pts (for 8-pt padding)
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor, constant: -16),
])
// add a target for the switch
theSwitch.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
// set based on saved state in UserDefaults
isWeekendOn = UserDefaults.standard.bool(forKey: "switchState")
// update the switch UI
theSwitch.isOn = isWeekendOn
}
#objc func switchChanged(_ sender: Any) {
// switch was tapped (toggled on/off)
if let v = sender as? UISwitch {
// update state in UserDefaults
UserDefaults.standard.set(v.isOn, forKey: "switchState")
// update the UI
isWeekendOn = v.isOn
}
}
func setupViews() {
// show or hide Sat and Sun table views
saturdayTableView.isHidden = !isWeekendOn
sundayTableView.isHidden = !isWeekendOn
}
}
Why don’t you make a horizontal collection view instead of the normal scroll view. It would be easier to call reloadData whenever you want to add or delete a cell (and of course each cell is a tableView)
Finally I have solved the problem.
UPDATE: The main reason I had to set up NotificationCenter for this matter, is that I used UITabBarController to add SettingsVC to my app instead of presenting it modally. Details below.
//Skip this part for answer
My main problem – as it turned out – was that my UISwitch was on a separate vc, called SettingsViewController.
This switch supposed to do the tableview-adding and removing on my main vc. I tried with delegate protocols, targeting shared instance of settingsVC, nothing worked, but adding a local button for this – which is definitely not what I wanted.
Then I read about NotificationCenter!
I remembered it from Apples App Development For Swift book,I read last year, but forgot since.
// So the Anwswer
After I set my constraints correctly based on the great hint of #DonMag, I set up NotificationCenter for my SettingsViewController, posting to my Main VC.
class SettingsViewController: UITableViewController {
private let reuseID = "reuseId"
lazy var switchButton: UISwitch = {
let sw = UISwitch()
sw.addTarget(self, action: #selector(switchPressed), for: .valueChanged)
sw.onTintColor = AdaptiveColors.navigationBarColor
return sw
}()
static let switchNotification = Notification.Name("SettingsController.switchNotification")
var isOn = Bool() {
didSet {
NotificationCenter.default.post(name:SettingsViewController.switchNotification, object: nil)
}
}
#objc func switchPressed(_ sender: UISwitch) {
UserDefaults.standard.set(sender.isOn, forKey: "switchState")
self.isOn = sender.isOn
}
then in the mainVC:
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
view.backgroundColor = .white
isWeekendOn = UserDefaults.standard.bool(forKey: "switchState")
// The Solution:
NotificationCenter.default.addObserver(self, selector: #selector(handleRefresh), name: SettingsViewController.switchNotification, object: nil)
dayTableViews = fiveOrSevenDayTableViews()
print("daytableviews count ", dayTableViews.count)
scrollView.delegate = self
editButtonItem.title = LocalizedString.edit
navigationItem.title = localizedDays[currentPage]
setupNavigationBar()
setupButtons()
setupTableViews()
setupViews()
isWeekendOn == true ? setupCurrentDayViewFor_7days() : setupCurrentDayViewFor_5days()
}
then here in mainVC's #objc func handleRefresh() { } i am handling the removal or addition!
UPDATE:
in SettingsVC:
static let switchOnNotification = Notification.Name("SettingsController.switchOnNotification")
static let switchOffNotification = Notification.Name("SettingsController.switchOffNotification")
var isOn = Bool() {
didSet {
}
willSet {
if newValue == true {
NotificationCenter.default.post(name:SettingsViewController.switchOnNotification, object: nil)
} else if newValue == false {
NotificationCenter.default.post(name:SettingsViewController.switchOffNotification, object: nil)
}
}
}
in viewDidLoad in mainVC:
NotificationCenter.default.addObserver(self, selector: #selector(handleAddWeekendTableViews), name: SettingsViewController.switchOnNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleRemoveWeekendTableViews), name: SettingsViewController.switchOffNotification, object: nil)
#objc func handleAddWeekendTableViews() {
[saturdayTableView, sundayTableView].forEach {
stackView.addArrangedSubview($0)
dayTableViews.append($0)
}
}
#objc func handleRemoveWeekendTableViews() {
manageCurrentPage()
dayTableViews.removeLast(2)
[saturdayTableView, sundayTableView].forEach {
$0.removeFromSuperview()
}
}
This one is actually working!