Search code examples
swiftautolayoutconstraintsuicontrol

Getting autolayout to work with a UIControl subclass


I have been able to get my custom control basically the way I want it, but I'd like it to work with autolayout. I've done a lot of trail and error but getting no where.

The control lays out as expected when I set a frame.

enter image description here

circleContol = CircleControl(size: 80, title: "My Button", circleStrokeSize: 4, image: testImage, imageColour: .yellow, circleColour: .red, textColour: .red)
circleContol.center = CGPoint(x: 150, y: 150)
view.addSubview(circleContol)

However, I'm struggling to get the control to play nice with Autolayout.

i.e using something like

NSLayoutConstraint.activate([
    circleContol.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    circleContol.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])

results in

enter image description here

I've played around with trying to set up autolayout in my the UIControl subclass, but unable to get it right. For example, I've tried the following, but i'm not sure if this is the correct approach, or best place to set this constraints - especially with the circle shapeLayer which seems to be a whole other issue.

override func updateConstraints() {

    NSLayoutConstraint.activate([
        imageOverlay.centerYAnchor.constraint(equalTo: centerYAnchor),
        imageOverlay.centerXAnchor.constraint(equalTo: centerXAnchor),
        imageOverlay.heightAnchor.constraint(equalToConstant: radius),
        imageOverlay.widthAnchor.constraint(equalToConstant: radius),
    ])

    super.updateConstraints()
}

Here is the playground liveView code, hopefully someone can provide some guidance. Its a lot of code, but I felt this was the best way for someone to see the issues

Thanks

Each example above can seen using these methods in the playGround

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    // 1. Lays out as expected
    createControl()

    // 2. Views all out of place
    // createUsingAutoLayout()
}

PlayGround Code

import UIKit
import PlaygroundSupport

class CircleControl: UIControl {

    private var shaperLayer: CAShapeLayer!
    private var stroke: CGFloat!
    private var padding: CGFloat!
    private var circleColour: UIColor!
    private var title: String!
    private var imageColour: UIColor!
    private var textColour: UIColor!
    private var imageOverlay: UIImageView!
    private let fontSize: CGFloat = 20

    private var actualRadius: CGFloat {
        let halfSquare: CGFloat = max(bounds.size.width, bounds.size.height) / 2
        let temp: CGFloat = halfSquare * halfSquare + halfSquare * halfSquare
        let radius: CGFloat = temp.squareRoot()
        return radius
    }

    private var radius: CGFloat {
        return actualRadius + padding + (stroke * 0.5)
    }

    private var textRadius: CGFloat {
        return actualRadius + padding + stroke + (fontSize * 0.5)
    }

    init(size: Int, title: String, circleStrokeSize: CGFloat, circlePadding: CGFloat = 0, image: UIImage, imageColour: UIColor, circleColour: UIColor, textColour: UIColor) {
        let f = CGRect(x: 0, y: 0, width: size, height: size)
        self.stroke = circleStrokeSize
        self.padding = circlePadding
        self.circleColour = circleColour
        self.imageColour = imageColour
        self.textColour = textColour
        self.title = title
        super.init(frame: f)

        // add subviews
        updateView()

        // add image to button
        let tinted = image.withRenderingMode(.alwaysTemplate)
        imageOverlay.image = tinted
    }

    private func addTextToView() {
        let label = CurvedTextView(frame: CGRect(x: 0, y: 0, width: 300, height: 300), title: title, r: textRadius, fontSize: fontSize, fontColour: textColour, location: .bottom)
        label.center = imageOverlay.center
        label.isUserInteractionEnabled = false
        label.backgroundColor = UIColor.clear
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        fatalError("init(coder:) has not been implemented")
    }

    func updateView() {
        addImageOverlay()
        addCircle()
        addTextToView()
    }

    func addImageOverlay() {
        imageOverlay = UIImageView(frame: bounds)
        imageOverlay.tintColor = imageColour
        imageOverlay.contentMode = .scaleAspectFill
        addSubview(imageOverlay)
        imageOverlay.isUserInteractionEnabled = false
        imageOverlay.translatesAutoresizingMaskIntoConstraints = false
    }

    func addCircle() {
        if let s = shaperLayer {
            s.removeFromSuperlayer()
        }

        shaperLayer = CAShapeLayer()
        let circularPath = UIBezierPath(arcCenter: UIView(frame: bounds).center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        shaperLayer.path = circularPath.cgPath
        shaperLayer.strokeColor = circleColour.cgColor
        shaperLayer.fillColor = UIColor.clear.cgColor
        shaperLayer.lineWidth = stroke
        layer.addSublayer(shaperLayer)
    }
}

class CurvedTextView: UIView {

    var radius: CGFloat!
    var fontSize: CGFloat!
    var fontColour: UIColor!
    var labelPost: LabelPostition = .bottom
    var title: String!

    init(frame: CGRect, title: String, r: CGFloat, fontSize: CGFloat, fontColour: UIColor, location: LabelPostition) {
        self.radius = r
        self.fontSize = fontSize
        self.fontColour = fontColour
        self.labelPost = location
        self.title = title
        super.init(frame: frame)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        fatalError("init(coder:) has not been implemented")
    }

    enum LabelPostition {
        case bottom
    }

    func centreArcPerpendicular(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool){
        let characters: [String] = str.map { String($0) }
        let l = characters.count
        let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font, NSAttributedString.Key.kern: 0.8]

        var arcs: [CGFloat] = []
        var totalArc: CGFloat = 0

        for i in 0 ..< l {
            arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
            totalArc += arcs[i]
        }

        let direction: CGFloat = clockwise ? -1 : 1
        let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2

        var thetaI = theta - direction * totalArc / 2

        for i in 0 ..< l {
            thetaI += direction * arcs[i] / 2
            centre(text: characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection)

            thetaI += direction * arcs[i] / 2
        }
    }

    func centre(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat) {
        let attributes = [NSAttributedString.Key.foregroundColor: c, NSAttributedString.Key.font: font]
        context.saveGState()
        context.scaleBy(x: 1, y: -1)
        context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
        context.rotate(by: -slantAngle)
        let offset = str.size(withAttributes: attributes)
        context.translateBy (x: -offset.width / 2, y: -offset.height / 2)
        str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
        context.restoreGState()
    }

    func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
        return 2 * asin(chord / (2 * radius))
    }

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }

        let size = self.bounds.size

        context.translateBy (x: size.width / 2, y: size.height / 2)
        context.scaleBy (x: 1, y: -1)

        let f = UIFont.systemFont(ofSize: fontSize, weight: UIFont.Weight.semibold)

        var startAngle: CGFloat = .pi
        var clock = true

        switch labelPost {
        case .bottom:
            startAngle = -(.pi / 2)
            clock = false
        }

        centreArcPerpendicular(text: title, context: context, radius: radius, angle: startAngle, colour: fontColour, font: f, clockwise: clock)
    }
}

class MyViewController : UIViewController {

    var circleContol: CircleControl!
    private let testImage = UIImage(named: "faceLike.png")!

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .lightGray
        self.view = view
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // 1. Lays out as expected
        createControl()

        // 2. Views all out of place
        // createUsingAutoLayout()
    }

    func createControl() {
        circleContol = CircleControl(size: 80, title: "My Button", circleStrokeSize: 4, image: testImage, imageColour: .yellow, circleColour: .red, textColour: .red)
        circleContol.center = CGPoint(x: 150, y: 150)
        view.addSubview(circleContol)
    }

    func createUsingAutoLayout() {
        circleContol = CircleControl(size: 150, title: "My Button", circleStrokeSize: 4, image: testImage, imageColour: .yellow, circleColour: .red, textColour: .red)
        view.addSubview(circleContol)

        circleContol.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            circleContol.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            circleContol.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

PlaygroundPage.current.liveView = MyViewController()

Solution

  • I can't run your playground but I may have some ideas.

    In your component set the position of your element with frames, that's why autoLayout will not affect it, you should add constraints.

    And you shouldn't set your contraints in updateConstraints method, constraints have to be set only one time for autoLayout to work.

    Try editing:

    private func addTextToView() {
        let label = CurvedTextView(frame: CGRect(x: 0, y: 0, width: 300, height: 300), title: title, r: textRadius, fontSize: fontSize, fontColour: textColour, location: .bottom)
        label.isUserInteractionEnabled = false
        label.backgroundColor = UIColor.clear
        label.translatesAutoresizingMaskIntoConstraints = false
    
        addSubview(label)
    
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor),
            label.bottomAnchor.constraint(equalTo: bottomAnchor),
            label.trailingAnchor.constraint(equalTo: trailingAnchor),
            label.leadingAnchor.constraint(equalTo: leadingAnchor)
            ])
    }
    
    func addImageOverlay() {
        imageOverlay = UIImageView(frame: bounds)
        imageOverlay.tintColor = imageColour
        imageOverlay.contentMode = .scaleAspectFill
        addSubview(imageOverlay)
        imageOverlay.isUserInteractionEnabled = false
        imageOverlay.translatesAutoresizingMaskIntoConstraints = false
    
        NSLayoutConstraint.activate([
            imageOverlay.topAnchor.constraint(equalTo: topAnchor),
            imageOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
            imageOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
            imageOverlay.leadingAnchor.constraint(equalTo: leadingAnchor)
        ])
    }
    

    Hope it helps


    The solution was trivial in the end

    All I needed to do ensure the imageView was in the correct location. The curved text and circle is based on the bounds size (which is equal to the imageView size). So, both the circle and the drawn text were automatically were fixed once imageView was in its rightful place.

    func addImageOverlay() {
        imageOverlay = UIImageView(frame: bounds)
        imageOverlay.tintColor = imageColour
        imageOverlay.contentMode = .scaleAspectFill
        addSubview(imageOverlay)
        imageOverlay.isUserInteractionEnabled = false
        imageOverlay.translatesAutoresizingMaskIntoConstraints = false
    
        NSLayoutConstraint.activate([
            imageOverlay.topAnchor.constraint(equalTo: topAnchor),
            imageOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
            imageOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
            imageOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
            imageOverlay.heightAnchor.constraint(equalToConstant: bounds.size.width),
            imageOverlay.widthAnchor.constraint(equalToConstant: bounds.size.width)
        ])
    }