Loading UI on a different thread in Swift

1.2k views Asked by At

I have a child controller that have a complicated UI views. My View is designed in storyboard. That causes the screen to freeze and it increases the chance of crashing if user kept tapping on the freezed screen.

Is it possible that I load the UI in a different thread and show the user an activity indicator??

I know that the complixity is in the check boxes in the middle. the checkboxes is a custom uibuttons. I draw them on drawRect. depending on selection, border width, dynamic border color, dynamic selected border color, backgroundcolor, selection background color.

enter image description here

Edit: note that the superview tag is not 500. this is a multiselection view.

func setupCheckBox(checkbox: CheckBox) {
    checkbox.setCornerRadius(radius: CGSize(width: checkbox.frame.size.width * 0.5, height: checkbox.frame.size.height * 0.5))
    checkbox.setBorderWidth(width: 2.0)
    checkbox.setBorderColor(border_color: UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0))
    checkbox.setSelection(selection_color: UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 0.9))
    checkbox.setSelected(selection: false)
    checkbox.setHit(edgeInsets: UIEdgeInsets(top: -4, left: -4, bottom: -4, right: -4))
    checkbox.delegate = self
}

CheckBox implementation:

protocol CheckBoxProtocol: class {
    func checkbox(checkBox: UIView, selection: Bool)
}

class CheckBox: RoundedCornersButton {

    var checked_icon: String?
    var unchecked_icon: String?

    weak var delegate: CheckBoxProtocol?

    var selectedd: Bool = false
    var allow_none: Bool = false
    var hitEdgeInsets: UIEdgeInsets?

    var selection_color: UIColor?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setHit(edgeInsets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0))

        selection_color = .clear
        backgroundColor = .clear
        addTarget(self, action: #selector(didTouchButton), for: .touchUpInside)
    }

    func setCheckedIcon(checked_icon: String) {
        self.checked_icon = checked_icon
        setSelectionTo(button: self, selected: selectedd, inform: nil)
    }

    func setUncheckedIcon(unchecked_icon: String) {
        self.unchecked_icon = unchecked_icon
        setSelectionTo(button: self, selected: selectedd, inform: nil)
    }

    func setSelection(selection_color: UIColor) {
        self.selection_color = selection_color
        setNeedsDisplay()
        layoutIfNeeded()
    }

    // if superview tag is equal to 500. all other checkbox in the superview will deselected.
    func didTouchButton() {
        if superview?.tag == 500 {
            for button: UIView in (superview?.subviews)! {
                if button is CheckBox && button != self {
                    setSelectionTo(button: button as! CheckBox, selected: false, inform:delegate)
                }
            }
            setSelectionTo(button: self as CheckBox, selected: allow_none ? (!selectedd) : true, inform:delegate)
        } else {
            setSelectionTo(button: self as CheckBox, selected: !selectedd, inform:delegate)
        }
    }

    func setSelectionTo(button: CheckBox, selected: Bool, inform: CheckBoxProtocol?) {
        button.selectedd = selected
        if selected {
            if checked_icon != nil {
                (button as CheckBox).setImage(UIImage.init(named: checked_icon!), for: .normal)
            }

            if color != .clear {
                button.setTitleColor(color, for: .normal)
            }
        } else {
            if unchecked_icon != nil {
                (button as CheckBox).setImage(UIImage.init(named: unchecked_icon!), for: .normal)
            }

            if selection_color != .clear {
                button.setTitleColor(selection_color, for: .normal)
            }
        }

        button.setNeedsDisplay()
        button.layoutIfNeeded()
        inform?.checkbox(checkBox: button, selection: selected)
    }

    func setSelected(selection: Bool) {
        super.isSelected = selection
        setSelectionTo(button: self, selected: selection, inform: nil)
        setNeedsDisplay()
        layoutIfNeeded()
    }

    func setHit(edgeInsets: UIEdgeInsets)
    {
        hitEdgeInsets = edgeInsets
    }

    // handeling hits on checkbox. taking in count the hitEdgeInsets. if hitEdgeInsets have minus values hits around the checkbox will be considered as a valid hits.
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        if UIEdgeInsetsEqualToEdgeInsets(hitEdgeInsets!, .zero) || !isEnabled || isHidden {
            return super.point(inside: point, with: event)
        }

        let relativeFrame: CGRect = self.bounds
        let hitFrame: CGRect = UIEdgeInsetsInsetRect(relativeFrame, hitEdgeInsets!)

        return hitFrame.contains(point)
    }

    func isSelectedd() -> Bool {
        return selectedd
    }

    func setAllowNone(_ allow: Bool) {
        allow_none = allow
    }

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        let context: CGContext = UIGraphicsGetCurrentContext()!

        context.setFillColor((selection_color?.cgColor)!)
        context.setStrokeColor((selection_color?.cgColor)!)

        let circlePath: UIBezierPath = UIBezierPath.init(arcCenter: CGPoint(x: frame.size.width * 0.5, y: frame.size.width * 0.5), radius: frame.size.width * 0.5 - borderWidth, startAngle: 0, endAngle: CGFloat(2.0 * Float(M_PI)), clockwise: true)

        if isSelectedd() {
            circlePath.fill()
            circlePath.stroke()
        }
    }
}

Rounded Corners Implementation:

class RoundedCornersButton: UIButton {

    var cornorRadius: CGSize!
    var borderWidth: CGFloat!
    var borderColor: UIColor!

    var color: UIColor!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        cornorRadius = CGSize(width: 12, height: 12)
        borderWidth = 0
        borderColor = UIColor.clear

        self.titleLabel?.numberOfLines = 1
        self.titleLabel?.adjustsFontSizeToFitWidth = true
        self.titleLabel?.lineBreakMode = .byClipping
        self.titleLabel?.baselineAdjustment = .alignCenters
        self.titleLabel?.textAlignment = .center

        // clear the background color to draw it on the graphics layer
        color = self.backgroundColor
        self.backgroundColor = UIColor.clear
    }

    func setColor(_color: UIColor) {
        color = _color
        self.setNeedsDisplay()
        self.layoutIfNeeded()
    }

    func setCornerRadius(radius: CGSize) {
        cornorRadius = radius
        self.setNeedsDisplay()
        self.layoutIfNeeded()
    }

    func setBorderWidth(width: CGFloat) {
        borderWidth = width
        self.setNeedsDisplay()
        self.layoutIfNeeded()
    }

    func setBorderColor(border_color: UIColor) {
        borderColor = border_color
        self.setNeedsDisplay()
        self.layoutIfNeeded()
    }

    // redraw without increasing or decreasing hiting area.
    override func draw(_ rect: CGRect) {
        // Drawing code
        super.draw(rect)

        // drawing context
        let context: CGContext = UIGraphicsGetCurrentContext()!

        // set fill color
        context.setFillColor(color.cgColor)

        // theme color
        let themeColor: UIColor = borderColor

        // set stroke color
        context.setStrokeColor(themeColor.cgColor.components!)

        // corners to round
        let corners: UIRectCorner = UIRectCorner.allCorners

        // bezier path rect

        let bezierRect: CGRect = CGRect(x: rect.origin.x+borderWidth*0.5, y: rect.origin.y+borderWidth*0.5, width: rect.size.width-borderWidth, height: rect.size.height-borderWidth)

        // rounded path
        let roundedPath: UIBezierPath = UIBezierPath.init(roundedRect: bezierRect, byRoundingCorners: corners, cornerRadii: cornorRadius)

        // set stroke width
        roundedPath.lineWidth = borderWidth

        // fill coloring
        roundedPath.fill()

        // stroke coloring
        roundedPath.stroke()
    }
}
3

There are 3 answers

10
Rob Napier On BEST ANSWER

I have a child controller that have a complicated UI views.

What you've shown here aren't that complicated. I assume you mean that computing where everything goes is complicated. If that's true, you need to compute some time other than during the draw cycle.

That causes the screen to freeze and it increases the chance of crashing if user kept tapping on the freezed screen.

If you're crashing due to the user interacting with a frozen view, then your problem isn't what you think it is. If you're hanging the event loop, all user touches are going to be ignored (since they're also on the event loop). But you may get a crash if you take too long to return from the event loop (~2s as I remember, though it may be shorter these days). See above; you must move any complex calculations somewhere else.

I know that the complixity is in the check boxes in the middle. the checkboxes is a custom uibuttons. I draw them on drawRect. depending on selection, border width, dynamic border color, dynamic selected border color, backgroundcolor, selection background color.

Excellent. Chasing down where the problem occurs is most of the solution. Your drawRect should be quite trivial for these, though. They're just circles. If your drawRect is doing anything other than checking pre-computed values, then you need to move all that computation somewhere else. drawRect generally should not compute very much. It has to be very fast.

If your problem is that there is a lot to compute and the computation itself takes a long time, then you need to move that to a background queue, display a placeholder view while you're computing it, and when it's done draw the real view.

If your problem is that there are a lot more subviews than we're seeing, you may have to reduce the number of subviews and do more custom drawing (but the screenshot you've provided doesn't look that complicated).

But at the end of the day you must update the UI on the main queue, and you must not block the main queue. So if you have anything complicated to do, you need to do it on another queue and update the UI when it's finished.

If your problem is the actual time required to haul things out of the Storyboard, you can try extracting this piece into its own Storyboard or NIB, or draw it programmatically (or just reduce the number of elements in the Storyboard), but I've never personally encountered a case where that was the fix.

5
Imad Ali On

Apple Documentation says,

"If your application has a graphical user interface, it is recommended that you receive user-related events and initiate interface updates from your application’s main thread. This approach helps avoid synchronization issues associated with handling user events and drawing window content. Some frameworks, such as Cocoa, generally require this behavior, but even for those that do not, keeping this behavior on the main thread has the advantage of simplifying the logic for managing your user interface."

For more information, Click here

10
BJ Miller On

Any time you need to touch anything involving the UI, put that code inside a dispatch to the main queue. For Swift 3, see the new DispatchQueue mechanism, otherwise, use the good ol' dispatch_async(...) and provide the main queue. A good tutorial on this is https://www.raywenderlich.com/79149/grand-central-dispatch-tutorial-swift-part-1.