Why is the trailing of my UISwitch sticking out the UIStackView?

80 views Asked by At

I don't know if this is a Swift bug or if I made a mistake, but there is a weird behaviour.

I have a UILabel and a UISwitch inside a horizontal UIStackView, but the trailing of the UISwitch is cut off because it's sticking out the stackView (see images).

App UI

UI debugger

Does anyone have a similar problem or is it just me? And if it's a Swift bug, is there a workaround?

Here is my code:

private lazy var alreadyInPlaceStackView: UIStackView = {
    UIStackView(arrangedSubviews: [alreadyInPlaceLabel, alreadyInPlaceSwitch],
                axis: .horizontal,
                spacing: Constants.horizontalPadding,
                alignment: .fill,
                distribution: .fill)
}()

private lazy var alreadyInPlaceLabel: UILabel = {
    let label = UILabel()
    label.textColor = UIColor.conference.background.on_background
    label.numberOfLines = 1
    label.font = UIFont.label
    label.text = "report.intubation.already-in-place.title".localized()

    return label
}()

private lazy var alreadyInPlaceSwitch: UISwitch = {
    let view = UISwitch()
    view.onTintColor = UIColor.conference.highlight.primary
    view.addTarget(self,
                   action: #selector(switchValueDidChange(_:)),
                   for: .valueChanged)
    view.setContentCompressionResistancePriority(.required, for: .horizontal)

    return view
}()

Constraints:

alreadyInPlaceStackView.leadingAnchor.constraint(equalTo: switchStackViewContainerView.leadingAnchor),
alreadyInPlaceStackView.trailingAnchor.constraint(equalTo: switchStackViewContainerView.trailingAnchor),
alreadyInPlaceStackView.topAnchor.constraint(equalTo: videoLaryngoscopeStackView.bottomAnchor, constant: Constants.verticalPadding),
alreadyInPlaceStackView.bottomAnchor.constraint(equalTo: switchStackViewContainerView.bottomAnchor),

switchStackViewContainerView.leadingAnchor.constraint(equalTo: detailsContainer.leadingAnchor),
switchStackViewContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
switchStackViewContainerView.topAnchor.constraint(equalTo: difficultIntubationStackView.bottomAnchor, constant: Constants.verticalPadding),
switchStackViewContainerView.bottomAnchor.constraint(equalTo: detailsContainer.bottomAnchor),

Thanks!

2

There are 2 answers

4
DonMag On

Decided to do a little more investigation on this... I was wrong about the "trailing" aspect.

Let's look at a simple example -- we'll add a blue UIView and a UISwitch, and constrain the width of the blue view to the width of the switch:

class ViewController: UIViewController {
    
    let theSwitch: UISwitch = {
        let v = UISwitch()
        v.backgroundColor = .green
        return v
    }()
    
    let blueView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemBlue
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        [blueView, theSwitch].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        
        view.addSubview(blueView)
        view.addSubview(theSwitch)
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // blueView width and height equal to theSwitch
            blueView.widthAnchor.constraint(equalTo: theSwitch.widthAnchor),
            blueView.heightAnchor.constraint(equalTo: theSwitch.heightAnchor),
            
            // blueView top and leading
            blueView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            blueView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            
            // theSwitch top to blueView bottom, leading to blueView leading
            theSwitch.topAnchor.constraint(equalTo: blueView.bottomAnchor, constant: 0.0),
            theSwitch.leadingAnchor.constraint(equalTo: blueView.leadingAnchor, constant: 0.0),
            
        ])
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("blueView  width:", blueView.frame.width)
        print("theSwitch width:", theSwitch.frame.width)
    }
    
}

Here's what we get:

enter image description here

and, if we tap, we see this in the Debug Console:

blueView  width: 49.0
theSwitch width: 51.0

The switch is 2-points wider than the blue view!!!


So, let's try setting the blue view's width constraint after the switch has been fully laid-out.

We comment-out the width constraint:

        // don't set blueView width
        //blueView.widthAnchor.constraint(equalTo: theSwitch.widthAnchor),
        // blueView height equal to theSwitch
        blueView.heightAnchor.constraint(equalTo: theSwitch.heightAnchor),

and on tap, we'll set it:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

    // now we set blueView width
    blueView.widthAnchor.constraint(equalTo: theSwitch.widthAnchor).isActive = true

    // print widths after layout pass
    DispatchQueue.main.async {
        print("blueView  width:", self.blueView.frame.width)
        print("theSwitch width:", self.theSwitch.frame.width)
    }

}

Looks like this before tap (because blueView has no width):

enter image description here

and after tap:

enter image description here

and console output is again:

blueView  width: 49.0
theSwitch width: 51.0

Equal is apparently not always Equal !!!


Let's look at a common use-case -- we will embed a label and a switch in a view (or, as in your example, a stack view) for "Option" toggles:

class ViewController: UIViewController {
    
    let theSwitch: UISwitch = {
        let v = UISwitch()
        v.backgroundColor = .green
        return v
    }()
    
    let blueView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemBlue
        return v
    }()
    
    let theLabel: UILabel = {
        let v = UILabel()
        v.text = "Option 1:"
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        [blueView, theSwitch, theLabel].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }

        // let's embed theSwitch and theLabel in blueView
        blueView.addSubview(theLabel)
        blueView.addSubview(theSwitch)
        view.addSubview(blueView)
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // we're going to right-align the switch in the blue view
            blueView.widthAnchor.constraint(equalToConstant: 128.0),
            
            // blueView height equal to theSwitch + 16 (so we have 8-points top/bottom "padding"
            blueView.heightAnchor.constraint(equalTo: theSwitch.heightAnchor, constant: 16.0),
            
            // blueView top and leading
            blueView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            blueView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            
            // center theLabel vertically and left-align it
            theLabel.centerYAnchor.constraint(equalTo: blueView.centerYAnchor),
            theLabel.leadingAnchor.constraint(equalTo: blueView.leadingAnchor, constant: 0.0),
            
            // center theSwitch vertically
            theSwitch.centerYAnchor.constraint(equalTo: blueView.centerYAnchor),
            
            // right-align theSwitch
            theSwitch.trailingAnchor.constraint(equalTo: blueView.trailingAnchor, constant: 0.0),
            
        ])
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        
        // clear the background colors
        blueView.backgroundColor = .clear
        theSwitch.backgroundColor = .clear
        
        // set clipsToBounds
        blueView.clipsToBounds = true
        
    }
    
}

and we get this:

enter image description here

On tap we'll clear the backgrounds, AND we'll turn on clipsToBounds for the blue view:

enter image description here


This is what I would call, clearly, a bug.

Worth noting... if we evaluate the intrinsicContentSize of the switch:

print("theSwitch.intrinsicContentSize:", theSwitch.intrinsicContentSize)
print("theSwitch.frame.size          :", theSwitch.frame.size)

Console shows:

theSwitch.intrinsicContentSize: (49.0, 31.0)
theSwitch.frame.size          : (51.0, 31.0)

Edit

Based on Matt's answer and explanation of the alignmentRectInsets of UISwitch, I'll retract my claim of this being a "bug" and instead accept that it is intended layout behavior.

I'll leave my example code and images here though (as an exercise in futility).

4
matt On

It's not a bug. This entire affair is based on a misunderstanding of the layout of a UISwitch. Run the app and output the UISwitch's alignmentRectInsets. You will see:

UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 2.0)

The effect you are seeing is exactly what the alignmentRectInsets is intended to do; it changes the point at which constraints are pinned to the view. Here, the right / trailing anchor is pinned to a point two points inset from the visible edge of the UISwitch.

This is a common technique when the physical size of a view does not correspond to the way the view is drawn. Here, I presume that the right-side curve is considered by Apple to be slightly outside the "true" body of the UISwitch.

You can complain to Apple if you want, but I doubt you'll get any satisfaction. Clearly Apple wants things to line up with the right end of a UISwitch in this way. If you don't like the way this works, you can simply compensate, knowing about the inset. Alternatively, you can make your own UISwitch subclass that changes the inset:

class MySwitch: UISwitch {
    override var alignmentRectInsets: UIEdgeInsets { .zero }
}