Fix two colours on UISlider swift - ios

Slider with two different colors
How can we make a slider with two fixed colors? The colors won't change even if the slider is moving. Also, the slider thumb should be able to side over any of those two colors. I should be able to define the length of the first section.
func createSlider(slider:UISlider) {
let frame = CGRect(x: 0.0, y: 0.0, width: slider.bounds.width, height: 4)
let tgl = CAGradientLayer()
tgl.frame = frame
tgl.colors = [UIColor.gray.cgColor,UIColor.red.cgColor]
tgl.endPoint = CGPoint(x: 0.4, y: 1.0)
tgl.startPoint = CGPoint(x: 0.0, y: 1.0)
UIGraphicsBeginImageContextWithOptions(tgl.frame.size, false, 0.0)
tgl.render(in: UIGraphicsGetCurrentContext()!)
let backgroundImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
slider.setMaximumTrackImage(backgroundImage?.resizableImage(withCapInsets:.zero), for: .normal)
slider.setMinimumTrackImage(backgroundImage?.resizableImage(withCapInsets:.zero), for: .normal)
}
I have already tried this. But this dosent give me exactly what I wanted to achieve.

Here's one approach...
set both min and max track images to "empty" images
add left and right subviews to act as "fake" track images
add a different color sublayer on each side subview
when you slide the thumb, update the frames of the layers
Here's some example code... you may want to do some tweaks:
class XebSlider: UISlider {
private var colors: [[UIColor]] = [
[.black, .black],
[.black, .black],
]
// left and right views will become the "fake" track images
private let leftView = UIView()
private let rightView = UIView()
// each view needs a layer to "change color"
private let leftShape = CALayer()
private let rightShape = CALayer()
// constraints that will be updated
private var lvWidth: NSLayoutConstraint!
private var lvLeading: NSLayoutConstraint!
private var rvTrailing: NSLayoutConstraint!
// how wide the two "sides" should be
private var leftPercent: CGFloat = 0
private var rightPercent: CGFloat = 0
// track our current width, so we don't repeat constraint updates
// unless our width has changed
private var currentWidth: CGFloat = 0
init(withLeftSidePercent leftWidth: CGFloat, leftColors: [UIColor], rightColors: [UIColor]) {
super.init(frame: .zero)
commonInit()
leftView.backgroundColor = leftColors[1]
rightView.backgroundColor = rightColors[1]
leftShape.backgroundColor = leftColors[0].cgColor
rightShape.backgroundColor = rightColors[0].cgColor
leftPercent = leftWidth
rightPercent = 1.0 - leftPercent
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// set both track images to "empty" images
setMinimumTrackImage(UIImage(), for: [])
setMaximumTrackImage(UIImage(), for: [])
// add left and right subviews
[leftView, rightView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
v.layer.cornerRadius = 4.0
v.layer.masksToBounds = true
v.isUserInteractionEnabled = false
insertSubview(v, at: 0)
}
// add sublayers
leftView.layer.addSublayer(leftShape)
rightView.layer.addSublayer(rightShape)
// create constraints whose .constant values will be modified in layoutSubviews()
lvLeading = leftView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
rvTrailing = rightView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
lvWidth = leftView.widthAnchor.constraint(equalToConstant: 0.0)
// avoids auto-layout complaints when the frame changes (such as on device rotation)
lvWidth.priority = UILayoutPriority(rawValue: 999)
// set constraints for subviews
NSLayoutConstraint.activate([
lvLeading,
rvTrailing,
lvWidth,
leftView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 0.0),
leftView.heightAnchor.constraint(equalToConstant: 8.0),
rightView.centerYAnchor.constraint(equalTo: leftView.centerYAnchor),
rightView.heightAnchor.constraint(equalTo: leftView.heightAnchor),
rightView.leadingAnchor.constraint(equalTo: leftView.trailingAnchor, constant: 1.0),
])
}
override func layoutSubviews() {
super.layoutSubviews()
// we only want to do this if the bounds width has changed
if bounds.width != currentWidth {
let trackRect = self.trackRect(forBounds: bounds)
lvLeading.constant = trackRect.origin.x
rvTrailing.constant = -(bounds.width - (trackRect.origin.x + trackRect.width))
lvWidth.constant = trackRect.width * leftPercent
}
// get percentage of thumb position
// based on min and max values
let pctValue = (self.value - self.minimumValue) / (self.maximumValue - self.minimumValue)
// calculate percentage of each side that needs to be "covered"
// by the different color layer
let leftVal = max(0.0, min(CGFloat(pctValue) / leftPercent, 1.0))
let rightVal = max(0.0, min((CGFloat(pctValue) - leftPercent) / rightPercent, 1.0))
var rLeft = leftView.bounds
var rRight = rightView.bounds
rLeft.size.width = leftView.bounds.width * leftVal
rRight.size.width = rightView.bounds.width * rightVal
// disable default layer animations
CATransaction.begin()
CATransaction.setDisableActions(true)
leftShape.frame = rLeft
rightShape.frame = rRight
CATransaction.commit()
}
}
and a controller example showing its usage:
class ViewController: UIViewController {
var slider: XebSlider!
override func viewDidLoad() {
super.viewDidLoad()
let leftSideColors: [UIColor] = [
#colorLiteral(red: 0.4796532989, green: 0.4797258377, blue: 0.4796373844, alpha: 1),
#colorLiteral(red: 0.8382737041, green: 0.8332912326, blue: 0.8421040773, alpha: 1),
]
let rightSideColors: [UIColor] = [
#colorLiteral(red: 0.9009097219, green: 0.3499996662, blue: 0.4638580084, alpha: 1),
#colorLiteral(red: 0.9591985345, green: 0.8522816896, blue: 0.8730568886, alpha: 1),
]
let leftSideWidthPercent: CGFloat = 0.5
slider = XebSlider(withLeftSidePercent: leftSideWidthPercent, leftColors: leftSideColors, rightColors: rightSideColors)
view.addSubview(slider)
slider.translatesAutoresizingMaskIntoConstraints = false
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain slider 40-pts from Top / Leading / Trailing
slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
slider.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
])
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
// to get teh custom slider to update properly
self.slider.setNeedsLayout()
}, completion: {
_ in
})
}
}
Result:

Related

How to fade out end of last line in multiline label?

Note, that it must work with different number of lines in UILabel - 1,2,3 etc.
I've already found solution for 1 line label, where you mask UILabel's layer with CAGradientLayer, but it doesn't work for multiline labels, as it masks the whole layer and fades out all lines.
I tried to make another CALayer with position calculated to be in the position of last line with desired width and used CAGradientLayer as mask and add this layer as sublayer of UILabel, it worked for static objects, but i use this UILabel in UITableViewCell and when it's tapped - it changes color to gray and i can see my layer, because it uses background color of UILabel when view layout its subviews, and also something wrong with x position calculation:
extension UILabel {
func fadeOutLastLineEnd() { //Call in layoutSubviews
guard bounds.width > 0 else { return }
lineBreakMode = .byCharWrapping
let tmpLayer = CALayer()
let gradientWidth: CGFloat = 32
let numberOfLines = CGFloat(numberOfLines)
tmpLayer.backgroundColor = UIColor.white.cgColor
tmpLayer.frame = CGRect(x: layer.frame.width - gradientWidth,
y: layer.frame.height / numberOfLines,
width: gradientWidth,
height: layer.frame.height / numberOfLines)
let tmpGrLayer = CAGradientLayer()
tmpGrLayer.colors = [UIColor.white.cgColor, UIColor.clear.cgColor]
tmpGrLayer.startPoint = CGPoint(x: 1, y: 0)
tmpGrLayer.endPoint = CGPoint(x: 0, y: 0)
tmpGrLayer.frame = tmpLayer.bounds
tmpLayer.mask = tmpGrLayer
layer.addSublayer(tmpLayer)
}
}
So, i need :
which can be multiline
end of last line needs to be faded out (gradient?)
works in UITableViewCell, when the whole object changes color
There are various ways to do this -- here's one approach.
We can mask a view by setting the layer.mask. The opaque areas of the mask will show-through, and the transparent areas will not.
So, what we need is a custom layer subclass that will look like this:
This is an example that I'll call InvertedGradientLayer:
class InvertedGradientLayer: CALayer {
public var lineHeight: CGFloat = 0
public var gradWidth: CGFloat = 0
override func draw(in inContext: CGContext) {
// fill all but the bottom "line height" with opaque color
inContext.setFillColor(UIColor.gray.cgColor)
var r = self.bounds
r.size.height -= lineHeight
inContext.fill(r)
// can be any color, we're going from Opaque to Clear
let colors = [UIColor.gray.cgColor, UIColor.gray.withAlphaComponent(0.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let colorLocations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: colorLocations)!
// start the gradient "grad width" from right edge
let startPoint = CGPoint(x: bounds.maxX - gradWidth, y: 0.5)
// end the gradient at the right edge, but
// probably want to leave the farthest-right 1 or 2 points
// completely transparent
let endPoint = CGPoint(x: bounds.maxX - 2.0, y: 0.5)
// gradient rect starts at the bottom of the opaque rect
r.origin.y = r.size.height - 1
// gradient rect height can extend below the bounds, becuase it will be clipped
r.size.height = bounds.height
inContext.addRect(r)
inContext.clip()
inContext.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: .drawsBeforeStartLocation)
}
}
Next, we'll make a UILabel subclass that implements that InvertedGradientLayer as a layer mask:
class CornerFadeLabel: UILabel {
let ivgLayer = InvertedGradientLayer()
override func layoutSubviews() {
super.layoutSubviews()
guard let f = self.font, let t = self.text else { return }
// we only want to fade-out the last line if
// it would be clipped
let constraintRect = CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
let boundingBox = t.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font : f], context: nil)
if boundingBox.height <= bounds.height {
layer.mask = nil
return
}
layer.mask = ivgLayer
ivgLayer.lineHeight = f.lineHeight
ivgLayer.gradWidth = 60.0
ivgLayer.frame = bounds
ivgLayer.setNeedsDisplay()
}
}
and here is a sample view controller showing it in use:
class FadeVC: UIViewController {
let wordWrapFadeLabel: CornerFadeLabel = {
let v = CornerFadeLabel()
v.numberOfLines = 1
v.lineBreakMode = .byWordWrapping
return v
}()
let charWrapFadeLabel: CornerFadeLabel = {
let v = CornerFadeLabel()
v.numberOfLines = 1
v.lineBreakMode = .byCharWrapping
return v
}()
let normalLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 1
return v
}()
let numLinesLabel: UILabel = {
let v = UILabel()
v.textAlignment = .center
return v
}()
var numLines: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let sampleText = "This is some example text that will wrap onto multiple lines and fade-out the bottom-right corner instead of truncating or clipping a last line."
wordWrapFadeLabel.text = sampleText
charWrapFadeLabel.text = sampleText
normalLabel.text = sampleText
let stack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 8
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let bStack: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.spacing = 8
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let btnUP: UIButton = {
let v = UIButton()
let cfg = UIImage.SymbolConfiguration(pointSize: 28.0, weight: .bold, scale: .large)
let img = UIImage(systemName: "chevron.up.circle.fill", withConfiguration: cfg)
v.setImage(img, for: [])
v.tintColor = .systemGreen
v.widthAnchor.constraint(equalTo: v.heightAnchor).isActive = true
v.addTarget(self, action: #selector(btnUpTapped), for: .touchUpInside)
return v
}()
let btnDown: UIButton = {
let v = UIButton()
let cfg = UIImage.SymbolConfiguration(pointSize: 28.0, weight: .bold, scale: .large)
let img = UIImage(systemName: "chevron.down.circle.fill", withConfiguration: cfg)
v.setImage(img, for: [])
v.tintColor = .systemGreen
v.widthAnchor.constraint(equalTo: v.heightAnchor).isActive = true
v.addTarget(self, action: #selector(btnDownTapped), for: .touchUpInside)
return v
}()
bStack.addArrangedSubview(btnUP)
bStack.addArrangedSubview(numLinesLabel)
bStack.addArrangedSubview(btnDown)
let v1 = UILabel()
v1.text = "Word-wrapping"
v1.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
let v2 = UILabel()
v2.text = "Character-wrapping"
v2.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
let v3 = UILabel()
v3.text = "Normal Label (Truncate Tail)"
v3.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
stack.addArrangedSubview(bStack)
stack.addArrangedSubview(v1)
stack.addArrangedSubview(wordWrapFadeLabel)
stack.addArrangedSubview(v2)
stack.addArrangedSubview(charWrapFadeLabel)
stack.addArrangedSubview(v3)
stack.addArrangedSubview(normalLabel)
stack.setCustomSpacing(20, after: bStack)
stack.setCustomSpacing(20, after: wordWrapFadeLabel)
stack.setCustomSpacing(20, after: charWrapFadeLabel)
view.addSubview(stack)
// dashed border views so we can see the lable frames
let wordBorderView = DashedView()
let charBorderView = DashedView()
let normalBorderView = DashedView()
wordBorderView.translatesAutoresizingMaskIntoConstraints = false
charBorderView.translatesAutoresizingMaskIntoConstraints = false
normalBorderView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wordBorderView)
view.addSubview(charBorderView)
view.addSubview(normalBorderView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
wordBorderView.topAnchor.constraint(equalTo: wordWrapFadeLabel.topAnchor, constant: 0.0),
wordBorderView.leadingAnchor.constraint(equalTo: wordWrapFadeLabel.leadingAnchor, constant: 0.0),
wordBorderView.trailingAnchor.constraint(equalTo: wordWrapFadeLabel.trailingAnchor, constant: 0.0),
wordBorderView.bottomAnchor.constraint(equalTo: wordWrapFadeLabel.bottomAnchor, constant: 0.0),
charBorderView.topAnchor.constraint(equalTo: charWrapFadeLabel.topAnchor, constant: 0.0),
charBorderView.leadingAnchor.constraint(equalTo: charWrapFadeLabel.leadingAnchor, constant: 0.0),
charBorderView.trailingAnchor.constraint(equalTo: charWrapFadeLabel.trailingAnchor, constant: 0.0),
charBorderView.bottomAnchor.constraint(equalTo: charWrapFadeLabel.bottomAnchor, constant: 0.0),
normalBorderView.topAnchor.constraint(equalTo: normalLabel.topAnchor, constant: 0.0),
normalBorderView.leadingAnchor.constraint(equalTo: normalLabel.leadingAnchor, constant: 0.0),
normalBorderView.trailingAnchor.constraint(equalTo: normalLabel.trailingAnchor, constant: 0.0),
normalBorderView.bottomAnchor.constraint(equalTo: normalLabel.bottomAnchor, constant: 0.0),
])
// set initial number of lines to 1
btnUpTapped()
}
#objc func btnUpTapped() {
numLines += 1
numLinesLabel.text = "Num Lines: \(numLines)"
wordWrapFadeLabel.numberOfLines = numLines
charWrapFadeLabel.numberOfLines = numLines
normalLabel.numberOfLines = numLines
}
#objc func btnDownTapped() {
if numLines == 1 { return }
numLines -= 1
numLinesLabel.text = "Num Lines: \(numLines)"
wordWrapFadeLabel.numberOfLines = numLines
charWrapFadeLabel.numberOfLines = numLines
normalLabel.numberOfLines = numLines
}
}
When running, it looks like this:
The red dashed borders are there just so we can see the frames of the labels. Tapping the up/down arrows will increment/decrement the max number of lines to show in each label.
You should create a CATextLayer with the same text properties as your UILabel.
Fill it with the end of your text you wish to fade.
Then calculate the position of this text segment in your UILabel.
Finally overlay the two.
Here are some aspect explained.

Gradient not applying to full view on larger screen

I implement a code to apply diagonal gradient to different views. it worked fine for all small views and all views on small screens but on iPhone 13 Pro Max simulator it doesn't apply to full view and leaves some part to the right side. I only noticed even in some other views which are wider this gradient only applies about 80% in width.
Here's the screenshot how its showing in one view:
It does not apply to right of the view and the right corner radius is invisible.
Here's the code for Gradient:
func applyGradientDiagonal(isVertical: Bool, colorArray: [UIColor]) {
layer.sublayers?.filter({ $0 is CAGradientLayer }).forEach({ $0.removeFromSuperlayer() })
let gradientLayer = CAGradientLayer()
gradientLayer.colors = colorArray.map({ $0.cgColor })
if isVertical {
//top to bottom
gradientLayer.locations = [0.0, 1.0]
} else {
//left to right
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
}
backgroundColor = .clear
gradientLayer.frame = bounds
layer.insertSublayer(gradientLayer, at: 0)
}
And I'm calling this in viewDidLoad() like this:
self.viewTAGradient.applyGradientDiagonal(isVertical: false, colorArray: [UIColor.init(named: "gradient1")!, UIColor.init(named: "gradient2")!, UIColor.init(named: "gradient3")!])
I looked for some solutions and even tried to call it like this but this also didn't work:
override func viewDidLayoutSubviews() {
self.viewTAGradient.applyGradientDiagonal(isVertical: false, colorArray: [UIColor.init(named: "gradient1")!, UIColor.init(named: "gradient2")!, UIColor.init(named: "gradient3")!])
}
Edit: New Update:
the issue is with all the gradients on iPhone 13 pro max. I copied some codes from internet for gradients and created new view controller with just one view of fixed height stuck to top and all shifted to left side on 13 pro max. works fine on other devices though. here's the screenshots:
Can anyone help me why on one particular device its behaving like that?
There's no reason for the gradient to behave differently on an iPhone 13 Pro Max, so my guess (without seeing your complete layout) would be related to constraints or when you're actually calling applyGradientDiagonal().
You may find it much easier to get reliable results by subclassing UIView and putting the gradient code inside layoutSubview().
Here's a simple example:
class MyGradientView: UIView {
var colorArray: [UIColor] = [] {
didSet {
setNeedsLayout()
}
}
var vertical: Bool = true {
didSet {
setNeedsLayout()
}
}
static override var layerClass: AnyClass {
return CAGradientLayer.self
}
override func layoutSubviews() {
super.layoutSubviews()
guard let layer = layer as? CAGradientLayer else { return }
layer.colors = colorArray.map({ $0.cgColor })
if vertical {
// top-down gradient
layer.startPoint = CGPoint(x: 0.5, y: 0.0)
layer.endPoint = CGPoint(x: 0.5, y: 1.0)
} else {
// diagonal gradient
layer.startPoint = CGPoint(x: 0.0, y: 0.0)
layer.endPoint = CGPoint(x: 1.0, y: 1.0)
}
}
}
and an example view controller to demonstrate:
class GradientTestViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
stack.distribution = .fillEqually
let colorPairs: [[UIColor]] = [
[.blue, .cyan],
[.red, .yellow],
[.blue, .yellow, .red],
]
// 3 pairs of gradient views
colorPairs.forEach { colors in
let hstack = UIStackView()
hstack.spacing = 8
hstack.distribution = .fillEqually
var v = MyGradientView()
v.colorArray = colors
v.vertical = true
hstack.addArrangedSubview(v)
v = MyGradientView()
v.colorArray = colors
v.vertical = false
hstack.addArrangedSubview(v)
stack.addArrangedSubview(hstack)
}
// a pair of plain, solid color views for comparison
let hstack = UIStackView()
hstack.spacing = 8
hstack.distribution = .fillEqually
var v = UIView()
v.backgroundColor = .systemRed
hstack.addArrangedSubview(v)
v = UIView()
v.backgroundColor = .systemBlue
hstack.addArrangedSubview(v)
stack.addArrangedSubview(hstack)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
stack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
])
// outline the stack view
// so we can see its frame
let outlineView = UIView()
outlineView.backgroundColor = .clear
outlineView.layer.borderColor = UIColor.black.cgColor
outlineView.layer.borderWidth = 3
outlineView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(outlineView)
NSLayoutConstraint.activate([
outlineView.topAnchor.constraint(equalTo: stack.topAnchor, constant: -2.0),
outlineView.leadingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -2.0),
outlineView.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 2.0),
outlineView.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 2.0),
])
}
}
The result of running that code:

UIScrollView draw ruler using drawRect

I am trying to draw a ruler on top of UIScrollView. The way I do it is by adding a custom view called RulerView. I add this rulerView to superview of scrollView setting its frame to be same as frame of scrollView. I then do custom drawing to draw lines as scrollView scrolls. But the drawing is not smooth, it stutters as I scroll and the end or begin line suddenly appears/disappears. What's wrong in my drawRect?
class RulerView: UIView {
public var contentOffset = CGFloat(0) {
didSet {
self.setNeedsDisplay()
}
}
public var contentSize = CGFloat(0)
let smallLineHeight = CGFloat(4)
let bigLineHeight = CGFloat(10)
override open func layoutSubviews() {
super.layoutSubviews()
self.backgroundColor = UIColor.clear
}
override func draw(_ rect: CGRect) {
UIColor.white.set()
let contentWidth = max(rect.width, contentSize)
let lineGap:CGFloat = 5
let totalNumberOfLines = Int(contentWidth/lineGap)
let startIndex = Int(contentOffset/lineGap)
let endIndex = Int((contentOffset + rect.width)/lineGap)
let beginOffset = contentOffset - CGFloat(startIndex)*lineGap
if let context = UIGraphicsGetCurrentContext() {
for i in startIndex...endIndex {
let path = UIBezierPath()
path.move(to: CGPoint(x: beginOffset + CGFloat(i - startIndex)*lineGap , y:0))
path.addLine(to: CGPoint(x: beginOffset + CGFloat(i - startIndex)*lineGap, y: i % 5 == 0 ? bigLineHeight : smallLineHeight))
path.lineWidth = 0.5
path.stroke()
}
}
}
And in the scrollview delegate, I set this:
//MARK:- UIScrollViewDelegate
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.x
rulerView.contentSize = scrollView.contentSize.width
rulerView.contentOffset = offset
}
Your override func draw(_ rect: CGRect) is very "heavy." I think you'll get much better performance by using a shape layer for your "tick marks" and letting UIKit handle the drawing.
Edit - as per comments
Added numbering to the tick marks using CATextLayer as sublayers.
Here's a sample RulerView (using your tick mark dimensions and spacing):
class RulerView: UIView {
public var contentOffset: CGFloat = 0 {
didSet {
layer.bounds.origin.x = contentOffset
}
}
public var contentSize = CGFloat(0) {
didSet {
updateRuler()
}
}
let smallLineHeight: CGFloat = 4
let bigLineHeight: CGFloat = 10
let lineGap:CGFloat = 5
// numbers under the tick marks
// with 12-pt system font .light
// 40-pt width will fit up to 5 digits
let numbersWidth: CGFloat = 40
let numbersFontSize: CGFloat = 12
var shapeLayer: CAShapeLayer!
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
shapeLayer = self.layer as? CAShapeLayer
// these properties don't change
backgroundColor = .clear
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.white.cgColor
shapeLayer.lineWidth = 0.5
shapeLayer.masksToBounds = true
}
func updateRuler() -> Void {
// size is set by .fontSize, so ofSize here is ignored
let numbersFont = UIFont.systemFont(ofSize: 1, weight: .light)
let pth = UIBezierPath()
var x: CGFloat = 0
var i = 0
while x < contentSize {
pth.move(to: CGPoint(x: x, y: 0))
pth.addLine(to: CGPoint(x: x, y: i % 5 == 0 ? bigLineHeight : smallLineHeight))
// number every 10 ticks - change as desired
if i % 10 == 0 {
let layer = CATextLayer()
layer.contentsScale = UIScreen.main.scale
layer.font = numbersFont
layer.fontSize = numbersFontSize
layer.alignmentMode = .center
layer.foregroundColor = UIColor.white.cgColor
// if we want to number by tick count
layer.string = "\(i)"
// if we want to number by point count
//layer.string = "\(i * Int(lineGap))"
layer.frame = CGRect(x: x - (numbersWidth * 0.5), y: bigLineHeight, width: numbersWidth, height: numbersFontSize)
shapeLayer.addSublayer(layer)
}
x += lineGap
i += 1
}
shapeLayer.path = pth.cgPath
}
}
and here's a sample controller class to demonstrate:
class RulerViewController: UIViewController, UIScrollViewDelegate {
var rulerView: RulerView = RulerView()
var scrollView: UIScrollView = UIScrollView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .blue
[scrollView, rulerView].forEach {
view.addSubview($0)
$0.translatesAutoresizingMaskIntoConstraints = false
}
// sample scroll content will be a horizontal stack view
// with 30 labels
// spaced 20-pts apart
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.spacing = 20
for i in 1...30 {
let v = UILabel()
v.textAlignment = .center
v.backgroundColor = .yellow
v.text = "Label \(i)"
stack.addArrangedSubview(v)
}
scrollView.addSubview(stack)
let g = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
// scroll view 20-pts Top / Leading / Trailing
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// scroll view Height: 60-pts
scrollView.heightAnchor.constraint(equalToConstant: 60.0),
// stack view 20-pts Top, 0-pts Leading / Trailing / Bottom (to scroll view's content layout guide)
stack.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 20.0),
stack.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
stack.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
stack.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// ruler view 4-pts from scroll view Bottom
rulerView.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 4.0),
rulerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
// ruler view 0-pts from scroll view Leading / Trailing (equal width and horizontal position of scroll view)
rulerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
// ruler view Height: 24-pts (make sure it's enough to accomodate ruler view's bigLineHeight plus numbering height)
rulerView.heightAnchor.constraint(equalToConstant: 24.0),
])
scrollView.delegate = self
// so we can see the sroll view frame
scrollView.backgroundColor = .red
// if we want to see the rulerView's frame
//rulerView.backgroundColor = .brown
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is when we know the scroll view's content size
rulerView.contentSize = scrollView.contentSize.width
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
// update rulerView's x-offset
rulerView.contentOffset = scrollView.contentOffset.x
}
}
Output:
the tick marks (and numbers) will, of course, scroll left-right synched with the scroll view.

Swift: Push Objects under the Label according to the number of lines

I'm trying to make the same behavior of the Material design textfield with a custom textfield.
I created a class that inherits from textfield and every thing is working fine. The only problem is in one scenario. when I have an object under the textfield, and i add the error label under the text field. the error label might be more than one line. so it overlays the object under the textfield. However, in the material design library, the objects under the textfield are automatically pushed down according ton the number of lines of the error label.
here is my custom textfield code:
import UIKit
import RxSwift
import RxCocoa
class FloatingTextField2: UITextField {
var placeholderLabel: UILabel!
var line: UIView!
var errorLabel: UILabel!
let bag = DisposeBag()
var activeColor = Constants.colorBlue
var inActiveColor = UIColor(red: 84/255.0, green: 110/255.0, blue: 122/255.0, alpha: 0.8)
var errorColorFull = UIColor(red: 254/255.0, green: 103/255.0, blue: 103/255.0, alpha: 1.0)
//var errorColorParcial = UIColor(red: 254/255.0, green: 103/255.0, blue: 103/255.0, alpha: 0.5)
private var lineYPosition: CGFloat!
private var lineXPosition: CGFloat!
private var lineWidth: CGFloat!
private var lineHeight: CGFloat!
private var errorLabelYPosition: CGFloat!
private var errorLabelXPosition: CGFloat!
private var errorLabelWidth: CGFloat!
private var errorLabelHeight: CGFloat!
var maxFontSize: CGFloat = 14
var minFontSize: CGFloat = 11
let errorLabelFont = UIFont(name: "Lato-Regular", size: 12)
var animationDuration = 0.35
var placeholderText: String = "" {
didSet {
if placeholderLabel != nil {
placeholderLabel.text = placeholderText
}
}
}
var isTextEntrySecured: Bool = false {
didSet {
self.isSecureTextEntry = isTextEntrySecured
}
}
override func draw(_ rect: CGRect) {
//setUpUI()
}
override func awakeFromNib() {
setUpUI()
}
func setUpUI() {
if placeholderLabel == nil {
placeholderLabel = UILabel(frame: CGRect(x: 0, y: 0, width: self.frame.width, height: 20))
self.addSubview(placeholderLabel)
self.borderStyle = .none
placeholderLabel.text = "Placeholder Preview"
placeholderLabel.textColor = inActiveColor
self.font = UIFont(name: "Lato-Regular", size: maxFontSize)
self.placeholderLabel.font = UIFont(name: "Lato-Regular", size: maxFontSize)
self.placeholder = ""
self.textColor = .black
setUpTextField()
}
if line == nil {
lineYPosition = self.frame.height
lineXPosition = -16
lineWidth = self.frame.width + 32
lineHeight = 1
line = UIView(frame: CGRect(x: lineXPosition, y: lineYPosition, width: lineWidth, height: lineHeight))
self.addSubview(line)
line.backgroundColor = inActiveColor
}
if errorLabel == nil {
errorLabelYPosition = lineYPosition + 8
errorLabelXPosition = 0
errorLabelWidth = self.frame.width
errorLabelHeight = calculateErrorLabelHeight(text: "")
errorLabel = UILabel(frame: CGRect(x: 0, y: errorLabelYPosition, width: errorLabelWidth, height: errorLabelHeight))
self.addSubview(errorLabel)
errorLabel.numberOfLines = 0
errorLabel.textColor = errorColorFull
errorLabel.text = ""
errorLabel.font = errorLabelFont
sizeToFit()
}
}
func setUpTextField(){
self.rx.controlEvent(.editingDidBegin).subscribe(onNext: { (next) in
if self.text?.isEmpty ?? false {
self.animatePlaceholderUp()
}
}).disposed(by: bag)
self.rx.controlEvent(.editingDidEnd).subscribe(onNext: { (next) in
if self.text?.isEmpty ?? false {
self.animatePlaceholderCenter()
}
}).disposed(by: bag)
}
func setErrorText(_ error: String?, errorAccessibilityValue: String?) {
if let errorText = error {
self.resignFirstResponder()
errorLabelHeight = calculateErrorLabelHeight(text: errorText)
self.errorLabel.frame = CGRect(x: 0, y: errorLabelYPosition, width: errorLabelWidth, height: errorLabelHeight)
self.errorLabel.text = errorText
self.errorLabel.isHidden = false
self.line.backgroundColor = errorColorFull
}else{
self.errorLabel.text = ""
self.errorLabel.isHidden = true
}
errorLabel.accessibilityIdentifier = errorAccessibilityValue ?? "textinput_error"
}
func animatePlaceholderUp(){
UIView.animate(withDuration: animationDuration, animations: {
self.line.frame.size.height = 2
self.line.backgroundColor = self.activeColor
self.placeholderLabel.font = self.placeholderLabel.font.withSize(self.minFontSize)
self.placeholderLabel.textColor = self.activeColor
self.placeholderLabel.frame = CGRect(x: 0, y: (self.frame.height/2 + 8) * -1, width: self.frame.width, height: self.frame.height)
self.layoutIfNeeded()
}) { (done) in
}
}
func animatePlaceholderCenter(){
UIView.animate(withDuration: animationDuration, animations: {
self.line.frame.size.height = 1
self.line.backgroundColor = self.inActiveColor
self.placeholderLabel.font = self.placeholderLabel.font.withSize(self.maxFontSize)
self.placeholderLabel.textColor = self.inActiveColor
self.placeholderLabel.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height)
self.layoutIfNeeded()
}) { (done) in
}
}
func calculateErrorLabelHeight(text:String) -> CGFloat{
let font = errorLabelFont
let width = self.frame.width
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
label.numberOfLines = 0
label.lineBreakMode = NSLineBreakMode.byWordWrapping
label.font = font
label.text = text
label.sizeToFit()
return label.frame.height
}
}
How can I solve this problem? I could not find anything on stack overflow or google related to my problem.
As mentioned in the comments:
You'll be much better off using constraints rather than explicit frames
Adding subviews to a UITextField will show them outside the Bounds of the field, meaning they won't affect the frame (and thus the constraints)
If the constraints are set properly, they will control the "containing view" height
The key to getting your "error" label to expand the view is to apply multiple vertical constraints, and activate / deactivate as needed.
Here is a complete example of a custom UIView which contains a text field, a placeholder label and an error label. The example view controller includes "demo" buttons to show the capabilities.
I suggest you add this code and try it out. If it suits your needs, there are plenty of comments in it that you should be able to tweak fonts, spacing, etc to your liking.
Or, it should at least give you some ideas of how to set up your own.
FloatingTextFieldView - UIView subclass
class FloatingTextFieldView: UIView, UITextFieldDelegate {
var placeHolderTopConstraint: NSLayoutConstraint!
var placeHolderCenterYConstraint: NSLayoutConstraint!
var placeHolderLeadingConstraint: NSLayoutConstraint!
var lineHeightConstraint: NSLayoutConstraint!
var errorLabelBottomConstraint: NSLayoutConstraint!
var activeColor: UIColor = UIColor.blue
var inActiveColor: UIColor = UIColor(red: 84/255.0, green: 110/255.0, blue: 122/255.0, alpha: 0.8)
var errorColorFull: UIColor = UIColor(red: 254/255.0, green: 103/255.0, blue: 103/255.0, alpha: 1.0)
var animationDuration = 0.35
var maxFontSize: CGFloat = 14
var minFontSize: CGFloat = 11
let errorLabelFont = UIFont(name: "Lato-Regular", size: 12)
let placeholderLabel: UILabel = {
let v = UILabel()
v.text = "Default Placeholder"
v.setContentHuggingPriority(.required, for: .vertical)
return v
}()
let line: UIView = {
let v = UIView()
v.backgroundColor = .lightGray
return v
}()
let errorLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.text = "Default Error"
v.setContentCompressionResistancePriority(.required, for: .vertical)
return v
}()
let textField: UITextField = {
let v = UITextField()
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
clipsToBounds = true
backgroundColor = .white
[textField, line, placeholderLabel, errorLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
addSubview($0)
}
// place holder label gets 2 vertical constraints
// top of view
// centerY to text field
placeHolderTopConstraint = placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
placeHolderCenterYConstraint = placeholderLabel.centerYAnchor.constraint(equalTo: textField.centerYAnchor, constant: 0.0)
// place holder leading constraint is 16-pts (when centered on text field)
// when animated above text field, we'll change the constant to 0
placeHolderLeadingConstraint = placeholderLabel.leadingAnchor.constraint(equalTo: textField.leadingAnchor, constant: 16.0)
// error label bottom constrained to bottom of view
// will be activated when shown, deactivated when hidden
errorLabelBottomConstraint = errorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
// line height constraint constant changes between 1 and 2 (inactive / active)
lineHeightConstraint = line.heightAnchor.constraint(equalToConstant: 1.0)
NSLayoutConstraint.activate([
// text field top 16-pts from top of view
// leading and trailing = 0
textField.topAnchor.constraint(equalTo: topAnchor, constant: 16.0),
textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
// text field height = 24
textField.heightAnchor.constraint(equalToConstant: 24.0),
// text field bottom is AT LEAST 4 pts
textField.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -4.0),
// line view top is 2-pts below text field bottom
// leading and trailing = 0
line.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 2.0),
line.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
line.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
// error label top is 4-pts from text field bottom
// leading and trailing = 0
errorLabel.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 4.0),
errorLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
errorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
placeHolderCenterYConstraint,
placeHolderLeadingConstraint,
lineHeightConstraint,
])
// I'm not using Rx, so set the delegate
textField.delegate = self
textField.font = UIFont(name: "Lato-Regular", size: maxFontSize)
textField.textColor = .black
placeholderLabel.font = UIFont(name: "Lato-Regular", size: maxFontSize)
placeholderLabel.textColor = inActiveColor
line.backgroundColor = inActiveColor
errorLabel.textColor = errorColorFull
errorLabel.font = errorLabelFont
}
func textFieldDidBeginEditing(_ textField: UITextField) {
if textField.text?.isEmpty ?? false {
self.animatePlaceholderUp()
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
if textField.text?.isEmpty ?? false {
self.animatePlaceholderCenter()
}
}
func animatePlaceholderUp() -> Void {
UIView.animate(withDuration: animationDuration, animations: {
// increase line height
self.lineHeightConstraint.constant = 2.0
// set line to activeColor
self.line.backgroundColor = self.activeColor
// set placeholder label font and color
self.placeholderLabel.font = self.placeholderLabel.font.withSize(self.minFontSize)
self.placeholderLabel.textColor = self.activeColor
// deactivate placeholder label CenterY constraint
self.placeHolderCenterYConstraint.isActive = false
// activate placeholder label Top constraint
self.placeHolderTopConstraint.isActive = true
// move placeholder label leading to 0
self.placeHolderLeadingConstraint.constant = 0
self.layoutIfNeeded()
}) { (done) in
}
}
func animatePlaceholderCenter() -> Void {
UIView.animate(withDuration: animationDuration, animations: {
// decrease line height
self.lineHeightConstraint.constant = 1.0
// set line to inactiveColor
self.line.backgroundColor = self.inActiveColor
// set placeholder label font and color
self.placeholderLabel.font = self.placeholderLabel.font.withSize(self.maxFontSize)
self.placeholderLabel.textColor = self.inActiveColor
// deactivate placeholder label Top constraint
self.placeHolderTopConstraint.isActive = false
// activate placeholder label CenterY constraint
self.placeHolderCenterYConstraint.isActive = true
// move placeholder label leading to 16
self.placeHolderLeadingConstraint.constant = 16
self.layoutIfNeeded()
}) { (done) in
}
}
func setErrorText(_ error: String?, errorAccessibilityValue: String?, endEditing: Bool) {
if let errorText = error {
UIView.animate(withDuration: 0.05, animations: {
self.errorLabel.text = errorText
self.line.backgroundColor = self.errorColorFull
self.errorLabel.isHidden = false
// activate error label Bottom constraint
self.errorLabelBottomConstraint.isActive = true
}) { (done) in
if endEditing {
self.textField.resignFirstResponder()
}
}
}else{
UIView.animate(withDuration: 0.05, animations: {
self.errorLabel.text = ""
self.line.backgroundColor = self.inActiveColor
self.errorLabel.isHidden = true
// deactivate error label Bottom constraint
self.errorLabelBottomConstraint.isActive = false
}) { (done) in
if endEditing {
self.textField.resignFirstResponder()
}
}
}
errorLabel.accessibilityIdentifier = errorAccessibilityValue ?? "textinput_error"
}
// func to set / clear element background colors
// to make it easy to see the frames
func showHideFrames(show b: Bool) -> Void {
if b {
self.backgroundColor = UIColor(red: 0.8, green: 0.8, blue: 1.0, alpha: 1.0)
placeholderLabel.backgroundColor = .cyan
errorLabel.backgroundColor = .green
textField.backgroundColor = .yellow
} else {
self.backgroundColor = .white
[placeholderLabel, errorLabel, textField].forEach {
$0.backgroundColor = .clear
}
}
}
}
DemoFLoatingTextViewController
class DemoFLoatingTextViewController: UIViewController {
// FloatingTextFieldView
let sampleFTF: FloatingTextFieldView = {
let v = FloatingTextFieldView()
return v
}()
// a label to constrain below the FloatingTextFieldView
// so we can see it gets "pushed down"
let demoLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.text = "This is a label outside the Floating Text Field. As you will see, it gets \"pushed down\" when the error label is shown."
v.backgroundColor = .brown
v.textColor = .yellow
return v
}()
// buttons to Demo the functionality
let btnA: UIButton = {
let b = UIButton(type: .system)
b.setTitle("End Editing", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnB: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Set Error", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnC: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Clear Error", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnD: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Set & End", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnE: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Clear & End", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnF: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Show Frames", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let btnG: UIButton = {
let b = UIButton(type: .system)
b.setTitle("Hide Frames", for: .normal)
b.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
return b
}()
let errorMessages: [String] = [
"Simple Error",
"This will end up being a Multiline Error message. It is long enough to cause word wrapping."
]
var errorCount: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
// add Demo buttons
let btnStack = UIStackView()
btnStack.axis = .vertical
btnStack.spacing = 6
btnStack.translatesAutoresizingMaskIntoConstraints = false
[[btnA], [btnB, btnC], [btnD, btnE], [btnF, btnG]].forEach { btns in
let sv = UIStackView()
sv.distribution = .fillEqually
sv.spacing = 12
sv.translatesAutoresizingMaskIntoConstraints = false
btns.forEach {
sv.addArrangedSubview($0)
}
btnStack.addArrangedSubview(sv)
}
view.addSubview(btnStack)
// add FloatingTextFieldView and demo label
view.addSubview(sampleFTF)
view.addSubview(demoLabel)
sampleFTF.translatesAutoresizingMaskIntoConstraints = false
demoLabel.translatesAutoresizingMaskIntoConstraints = false
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// buttons stack Top = 20, centerX, width = 80% of view width
btnStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
btnStack.centerXAnchor.constraint(equalTo: g.centerXAnchor),
btnStack.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
// FloatingTextFieldView Top = 40-pts below buttons stack
sampleFTF.topAnchor.constraint(equalTo: btnStack.bottomAnchor, constant: 40.0),
// FloatingTextFieldView Leading = 60-pts
sampleFTF.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
// FloatingTextFieldView width = 240
sampleFTF.widthAnchor.constraint(equalToConstant: 240.0),
// Note: we are not setting the FloatingTextFieldView Height!
// constrain demo label Top = 8-pts below FloatingTextFieldView bottom
demoLabel.topAnchor.constraint(equalTo: sampleFTF.bottomAnchor, constant: 8.0),
// Leading = FloatingTextFieldView Leading
demoLabel.leadingAnchor.constraint(equalTo: sampleFTF.leadingAnchor),
// Width = 200
demoLabel.widthAnchor.constraint(equalToConstant: 200.0),
])
// add touchUpInside targets for demo buttons
btnA.addTarget(self, action: #selector(endEditing(_:)), for: .touchUpInside)
btnB.addTarget(self, action: #selector(setError(_:)), for: .touchUpInside)
btnC.addTarget(self, action: #selector(clearError(_:)), for: .touchUpInside)
btnD.addTarget(self, action: #selector(setAndEnd(_:)), for: .touchUpInside)
btnE.addTarget(self, action: #selector(clearAndEnd(_:)), for: .touchUpInside)
btnF.addTarget(self, action: #selector(showFrames(_:)), for: .touchUpInside)
btnG.addTarget(self, action: #selector(hideFrames(_:)), for: .touchUpInside)
}
#objc func endEditing(_ sender: Any) -> Void {
sampleFTF.textField.resignFirstResponder()
}
#objc func setError(_ sender: Any) -> Void {
sampleFTF.setErrorText(errorMessages[errorCount % 2], errorAccessibilityValue: "", endEditing: false)
errorCount += 1
}
#objc func clearError(_ sender: Any) -> Void {
sampleFTF.setErrorText(nil, errorAccessibilityValue: "", endEditing: false)
}
#objc func setAndEnd(_ sender: Any) -> Void {
sampleFTF.setErrorText(errorMessages[errorCount % 2], errorAccessibilityValue: "", endEditing: true)
errorCount += 1
}
#objc func clearAndEnd(_ sender: Any) -> Void {
sampleFTF.setErrorText(nil, errorAccessibilityValue: "", endEditing: true)
}
#objc func showFrames(_ sender: Any) -> Void {
sampleFTF.showHideFrames(show: true)
}
#objc func hideFrames(_ sender: Any) -> Void {
sampleFTF.showHideFrames(show: false)
}
}
Example results:

iOS StackViews with proportionally filled Subviews and intrinsic width not behaving as expected

Following this article I was trying to get proportional sizing on StackViews working.
https://spin.atomicobject.com/2017/02/07/uistackviev-proportional-custom-uiviews/
The assumption was that by overriding intrinsicContentSize we can specify a new number and it will figure out the ratio of the sizes of subViews and resize the views accordingly.
When I repeat the implementation I am getting some odd behaviour. The ratio is preserved but the last item is stretched to take up the remaining space instead of the items being scaled across the entire width of the parent view (see image below).
Code:
class GuageSection: UIView {
var width: Double = 1.0
override var intrinsicContentSize: CGSize {
return CGSize(width: width, height: 1.0)
}
}
which is used like this
var guageWrapper = UIStackView()
guageWrapper.distribution = .fillProportionally
let guageSection = GuageSection()
guageSection.width = category.range // Currently Doubles ranging between 1.0 and 1.5
guageWrapper.addArrangedSubview(guageSection)
I have tried playing with the translateAutoResizingMaskInConstraints property and a few other things but nothing seems to change this behaviour.
If anyone has seen this behaviour before a good point in the right direction would be very much appreciated.
I don't know whether this is a "bug" or not, but... It appears UIStackView has an issue with .fillProportionally and its initial layout calculations.
If .spacing is 0 (zero), .fillProportionally seems to work as documented. If .spacing is non-Zero, we see issues.
So, try this... Initialize your stack view with spacing of 0, then:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guageWrapper.spacing = 2
}
You'll need a reference to guageWrapper, of course, so create it as a class-level var.
Edit:
I whipped up an example with the stack view as part of a custom UIView.
Using an "intrinsic widths" array of 1.0, 2.0, 1.0, 1.0, 1.5, here is the result:
Everything is done via code (no #IBOutlets needed), so you should be able to run this by adding a new view controller and setting its custom class to GuageTestViewController:
//
// GuageTestViewController.swift
//
// Created by Don Mag on 2/28/19.
//
import UIKit
class GuageSection: UIView {
let label: UILabel = {
let v = UILabel()
v.textAlignment = .center
v.numberOfLines = 0
v.font = UIFont.systemFont(ofSize: 14.0)
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let colorView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
var width: Double = 1.0
override var intrinsicContentSize: CGSize {
return CGSize(width: width, height: 1.0)
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
self.addSubview(colorView)
self.addSubview(label)
NSLayoutConstraint.activate([
colorView.topAnchor.constraint(equalTo: self.topAnchor, constant: 0.0),
colorView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0.0),
colorView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0.0),
colorView.heightAnchor.constraint(equalToConstant: 10.0),
label.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0.0),
label.centerXAnchor.constraint(equalTo: self.centerXAnchor, constant: 0.0),
label.widthAnchor.constraint(equalTo: colorView.widthAnchor, constant: 0.0),
label.topAnchor.constraint(equalTo: colorView.bottomAnchor, constant: 2.0),
])
}
}
class GuageView: UIView {
var pStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
self.backgroundColor = UIColor(red: 41.0 / 255.0, green: 59.0 / 255.0, blue: 78.0 / 255.0, alpha: 1.0)
pStack.translatesAutoresizingMaskIntoConstraints = false
pStack.axis = .horizontal
pStack.alignment = .fill
pStack.distribution = .fillProportionally
pStack.spacing = 0
addSubview(pStack)
let labels = [
"Low", "Ideal", "Pre-High", "High", "Very High"
]
let rgbVals = [
[252, 191, 127],
[ 79, 197, 140],
[252, 191, 127],
[249, 129, 131],
[217, 92, 98],
]
let widths = [
1.0, 2.0, 1.0, 1.0, 1.5
]
for i in 0..<labels.count {
let v = GuageSection()
v.translatesAutoresizingMaskIntoConstraints = false
v.label.text = labels[i]
v.width = widths[i]
let rgb = rgbVals[i].compactMap { CGFloat($0) / 255.0 }
v.colorView.backgroundColor = UIColor(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1.0)
v.label.textColor = v.colorView.backgroundColor
pStack.addArrangedSubview(v)
}
// constrain the stack view 20-pts from top, leading and trailing, 8-pts from bottom
NSLayoutConstraint.activate([
pStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.0),
pStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20.0),
pStack.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
pStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
// no height constraint ...
// let the guageSection view height determine the stack view height
// guageSection has 10-pt tall view and multi-line capable label
])
}
override func layoutSubviews() {
super.layoutSubviews()
pStack.spacing = 2
}
}
class GuageTestViewController: UIViewController {
var gView = GuageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 31.0 / 255.0, green: 46.0 / 255.0, blue: 61.0 / 255.0, alpha: 1.0)
view.addSubview(gView)
gView.translatesAutoresizingMaskIntoConstraints = false
// constrain the view to leading and trailing, and 40-pts from the top
NSLayoutConstraint.activate([
gView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0.0),
gView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0.0),
gView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40.0),
// no height constraint ...
// let the GuageView's content determine the height
])
}
}

Resources