Swift 3 - Custom button (image and text) only draws correctly if there's no outlet? - swift

EDIT: Although I accepted an answer that helped me get the buttons drawn correctly at runtime, I still have other issues. The root of which, I suspect, is the issue of why giving my custom button an outlet interferes with how it is drawn. I still need to know why this is. (see my answer below)
I have my own button class that extends UIButton (see below) and has several IBInspectable properties like border width/color, corner radius, and even start/end colors for a gradient background. I also use such properties for setting the image and titles' insets programmatically so I can account for various screen sizes.
Previously I had an issue where if I changed the "View as" device in the storyboard, let's say from iPhone SE to iPhone 7, and then refreshed the views and ran on a physical iPhone SE it would deploy with the insets of an iPhone 7 instead of calculating the insets with the physical device's own screen size. However, I nearly resolved this problem by overriding draw in my custom button class.
My problem now is that the overridden draw method is only called (or seems to be effective only when) the button has no outlet to the ViewController class it's placed in.
For example:
*The img is a placeholder; the insets suit the real imgs appropriately.
That bottom-right button has the gradient and corners drawn properly, where the other 3 do not. I have confirmed that adding an outlet to this button makes it behave like the rest, and removing an outlet from any of the other 3 causes it to be drawn correctly.
For reference, the outlets are just
#IBOutlet weak var button1: UIButton!
#IBOutlet weak var button2: UIButton!
#IBOutlet weak var button3: UIButton!
immediately under the class MyViewController: UIViewController { declaration.
Also, I didn't forget to set the Custom Class in the interface builder for each button.
Here is the button class:
import UIKit
#IBDesignable
class ActionButton: UIButton {
override init(frame: CGRect){
super.init(frame: frame)
setupView()
}
required init(coder aDecoder: NSCoder){
super.init(coder: aDecoder)!
setupView()
}
#IBInspectable var cornerRadius: CGFloat = 0{
didSet{
setupView()
}
}
#IBInspectable var startColor: UIColor = UIColor.white{
didSet{
setupView()
}
}
#IBInspectable var endColor: UIColor = UIColor.black{
didSet{
setupView()
}
}
#IBInspectable var borderWidth: CGFloat = 0{
didSet{
self.layer.borderWidth = borderWidth
}
}
#IBInspectable var borderColor: UIColor = UIColor.clear{
didSet{
self.layer.borderColor = borderColor.cgColor
}
}
private func setupView(){
let colors:Array = [startColor.cgColor, endColor.cgColor]
gradientLayer.colors = colors
gradientLayer.cornerRadius = cornerRadius
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
self.setNeedsDisplay()
}
var gradientLayer: CAGradientLayer{
return layer as! CAGradientLayer
}
override func draw(_ rect: CGRect) {
//Draw image
setupView()
let btnW = self.frame.size.width
let btnH = self.frame.size.height
//Compute and set image insets
let topBtnInset = btnH * (-5/81)
let bottomBtnInset = -4 * topBtnInset
let leftBtnInset = btnW / 4 //btnW * (35/141)
let rightBtnInset = leftBtnInset
self.imageEdgeInsets = UIEdgeInsets(top: topBtnInset, left: leftBtnInset, bottom: bottomBtnInset, right: rightBtnInset)
//Compute and set title insets
let topTitleInset = btnH * (47/81)
let bottomTitleInset = CGFloat(0)
let leftTitleInset = CGFloat(-256) //Image width
let rightTitleInset = CGFloat(0)
self.titleEdgeInsets = UIEdgeInsets(top: topTitleInset, left: leftTitleInset, bottom: bottomTitleInset, right: rightTitleInset)
//Draw text
//Default font size
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 15)
if(btnH > 81){
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 17)
}else if(btnH > 97){
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 20)
}
}
override class var layerClass: AnyClass{
return CAGradientLayer.self
}
}
Please ignore the wonky way I handled insets, font sizes, etc..
Does anyone know why specifying an outlet for the button makes it not draw the layer correctly?

A couple of thoughts:
The draw(_:) method is for stroking paths, drawing images, etc. It is for rendering the view at a given moment of time. You should not be updating layers, UIKit subviews, etc.
In your snippet, your draw(_:) is calling setupView, which then calls setNeedsDisplay (which theoretically could trigger draw(_:) to be called again). I'd suggest reserving draw(_:) for only those things that need to be manually drawn. (As it is now, you probably could completely eliminate this method as there's nothing here that is drawing anything.)
For any manual configuration of subviews (their frames, etc.), that should be moved to layoutSubviews, which is called whenever the OS determines that the subviews might need to have their frame values adjusted.
If you have any troubles with designable views, it's worth confirming that you have these in a separate target. It minimizes what needs to be recompiled when rendering in IB. It also prevents issues in the main target affecting the ability to render these designables in IB. (When Apple introduced designable views, they were very specific that one should put them in a separate target.)
I've also had intermittent problems with designable views in Xcode, but these problems are often resolved by quitting Xcode, emptying the derived data folder, and restarting.
I must confess that I do not know why adding an outlet to a designable view would affect how the view is rendered. I know that when I did the above, though, I was unable to reproduce your problem.
Anyway, I tweaked your ActionButton like so, and it appears to work for me:
#import UIKit
#IBDesignable
public class ActionButton: UIButton {
public override init(frame: CGRect = .zero) {
super.init(frame: frame)
setupView()
}
public required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
setupView()
}
#IBInspectable public var cornerRadius: CGFloat = 0 { didSet { setupView() } }
#IBInspectable public var startColor: UIColor = .white { didSet { setupView() } }
#IBInspectable public var endColor: UIColor = .black { didSet { setupView() } }
#IBInspectable public var borderWidth: CGFloat = 0 { didSet { layer.borderWidth = borderWidth } }
#IBInspectable public var borderColor: UIColor = .clear { didSet { layer.borderColor = borderColor.cgColor } }
private func setupView() {
let colors = [startColor.cgColor, endColor.cgColor]
gradientLayer.colors = colors
gradientLayer.cornerRadius = cornerRadius
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
}
private var gradientLayer: CAGradientLayer {
return layer as! CAGradientLayer
}
public override func layoutSubviews() {
super.layoutSubviews()
let btnW = frame.width
let btnH = frame.height
//Compute and set image insets
let topBtnInset = btnH * (-5/81)
let bottomBtnInset = -4 * topBtnInset
let leftBtnInset = btnW / 4 //btnW * (35/141)
let rightBtnInset = leftBtnInset
imageEdgeInsets = UIEdgeInsets(top: topBtnInset, left: leftBtnInset, bottom: bottomBtnInset, right: rightBtnInset)
//Compute and set title insets
let topTitleInset = btnH * (47/81)
let bottomTitleInset = CGFloat(0)
let leftTitleInset = CGFloat(-256) //THIS MUST BE EQUAL TO -IMAGE WIDTH
let rightTitleInset = CGFloat(0)
titleEdgeInsets = UIEdgeInsets(top: topTitleInset, left: leftTitleInset, bottom: bottomTitleInset, right: rightTitleInset)
//Adjust text font
if btnH > 81 {
titleLabel?.font = .boldSystemFont(ofSize: 17)
} else if btnH > 97 {
titleLabel?.font = .boldSystemFont(ofSize: 20)
} else {
//Default font size
titleLabel?.font = .boldSystemFont(ofSize: 15)
}
}
public override class var layerClass: AnyClass {
return CAGradientLayer.self
}
}

I've accepted Rob's answer because it is far more likely to be of use to anyone else, but I've found what specifically was my issue, on the off-chance someone has a similar problem.
Apparently in my code for the View Controller on which those buttons were placed I had a function I had forgot about that enables/disables those custom buttons, as well as set their background color (to a solid color). This is why those buttons looked wrong. The 4th button was never subject to mutation by this function. It was not the fact the other 3 had an outlet, but that when I removed the outlet I commented-out the call to that function.
Nevertheless, other problems were addressed in this question!
-I had a really sloppy implementation of a UIButton that overrode and called UI functions improperly, that caused problems like freezing on rotation. These were also addressed in Rob's (the accepted) answer.
-"Refresh All Views" in the Interface Builder was hanging on "Signing product", which was caused by calling self.titleLabel?.font. According to some answers on the Apple developer forums, doing this in layoutSubViews() is bad!

Related

Tried making a custom ScrollView, but instead of scrolling it's spamming up and down

I tried to create some kind of timeline (with the Vector Illustrator mentality), using UIBezier and UI Label (kind of like in the calendar app) and then use UIPanGestureRecognizer to scroll it up and down. But whenever I scroll it in the simulator, it multiplies itself instead of moving like the images below (I use setNeedsDisplay as the scrollValue changes to redraw the whole mechanism). This is probably a small mistake a I did or maybe my code doesn't work.
I know I could use a UIScrollView or UITableView instead, but I tried making this as a small challenge as a custom made table because using pre-made objects feels limiting for someone like me who is used to CAD drawing or Vector Illustrator.
This image explains what happens in the Simulator:
The code I used is below:
import UIKit
class ViewController: UIViewController {
var tlobject = TimelineView()
let gesto = UIPanGestureRecognizer()
override func viewDidLoad() {
super.viewDidLoad()
// ===== Add TimelineView Object to view
let TLObjectFrame = CGRect(x: 0, y: 40, width: 100, height: 100)
tlobject = TimelineView(frame: TLObjectFrame)
view.addSubview(tlobject)
// ===== ADD TOUCH GESTURE =====
gesto.addTarget(self, action: #selector(touchinput))
view.addGestureRecognizer(gesto)
}
var touchStartLocation: Int = 0
var scrollDistance: Int = 0
var lastScrollDistance: Int = 0
//The following func calculates the distance scrolled/travelled by Touch gesture on the YAxis and sends the result value (scrollDistance) to the Timeline mechanism where it defines the Yposition of every UIBezier. Thanks to Mitchell Hudson on Youtube for helping me figure out how to do it on his Tutorial "06 11 touches value"
#objc func touchinput (sender: UIPanGestureRecognizer) {
if sender.state == UIGestureRecognizer.State.began {
touchStartLocation = Int(sender.location(in: view).y)
lastScrollDistance = scrollDistance
}
if sender.state == UIGestureRecognizer.State.changed {
let touchEndLocation = Int(sender.location(in: view).y)
let currentScrollDistance = touchEndLocation - touchStartLocation
print("deltaY", currentScrollDistance)
var newScrollDistance = lastScrollDistance + currentScrollDistance
scrollDistance = newScrollDistance
tlobject.totalScrollDistance = scrollDistance //send scrollValue to TimelineView
}
if sender.state == UIGestureRecognizer.State.ended {
print("lastScrollDistance", lastScrollDistance)
print("scroll Distance", scrollDistance)
}
}
}
//Created a new View with the TimeLine mechanism
class TimelineView: UIView {
var totalScrollDistance: Int = 0 {
didSet{
setNeedsDisplay() //this gets called everytime UIgesture position changes
}
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
timelinemechanism()
}
func timelinemechanism() {
let lineElements: Array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
let spacing: Int = 30
let scrollDistance: Int = totalScrollDistance
let totalElements: Int = lineElements.count
for n in 1...totalElements {
//Get UILabel/UILine Yposition on screen = Array index number * the spacing + scroll distance by touch pan gesture
let yPosition = lineElements[n - 1] * spacing + scrollDistance
let linepath = UIBezierPath()
linepath.move(to: CGPoint(x: 60, y: yPosition))
linepath.addLine(to: CGPoint(x: 300, y: yPosition))
let lineshape = CAShapeLayer()
lineshape.path = linepath.cgPath
lineshape.strokeColor = UIColor.blue.cgColor
//lineshape.fillColor = UIColor.white.cgColor
//lineshape.lineWidth = 1
self.layer.addSublayer(lineshape)
let hourlabel = UILabel()
hourlabel.frame = CGRect(x: 5, y: yPosition - 20, width: 45, height: 40)
hourlabel.text = "\(n):00"
//hourlabel.font = UIFont(name: "Avenir-Claro", size: 12)
hourlabel.textColor = UIColor.blue
hourlabel.textAlignment = NSTextAlignment.right
self.addSubview(hourlabel)
}
}
}
Inside draw you only have to draw something. You add new subviews/sublayers and do not remove old ones.
Creating a new view every time you change a frame is very resource-intensive. And you don't need that, because you have the same views, you only need to change the position.
Instead, you can create your views at start and use layoutSubviews to update your views positions:
class TimelineView: UIView {
var totalScrollDistance: Int = 0 {
didSet{
setNeedsLayout() //this gets called everytime UIgesture position changes
}
}
private var lastLayoutTotalScrollDistance: Int = 0
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
createTimelinemechanism()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var lineShapes = [CAShapeLayer]()
var hourLabels = [UILabel]()
override func layoutSubviews() {
super.layoutSubviews()
let offset = totalScrollDistance - lastLayoutTotalScrollDistance
lastLayoutTotalScrollDistance = totalScrollDistance
lineShapes.forEach { lineShape in
lineShape.frame.origin.y += CGFloat(offset)
}
hourLabels.forEach { hourLabel in
hourLabel.frame.origin.y += CGFloat(offset)
}
}
func createTimelinemechanism() {
let lineElements: Array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
let spacing: Int = 30
let totalElements: Int = lineElements.count
for n in 1...totalElements {
//Get UILabel/UILine Yposition on screen = Array index number * the spacing + scroll distance by touch pan gesture
let yPosition = lineElements[n - 1] * spacing
let linepath = UIBezierPath()
linepath.move(to: CGPoint(x: 60, y: yPosition))
linepath.addLine(to: CGPoint(x: 300, y: yPosition))
let lineshape = CAShapeLayer()
lineshape.path = linepath.cgPath
lineshape.strokeColor = UIColor.blue.cgColor
//lineshape.fillColor = UIColor.white.cgColor
//lineshape.lineWidth = 1
// disable default layer position animation
lineshape.actions = [
"position": NSNull(),
]
self.layer.addSublayer(lineshape)
lineShapes.append(lineshape)
let hourlabel = UILabel()
hourlabel.frame = CGRect(x: 5, y: yPosition - 20, width: 45, height: 40)
hourlabel.text = "\(n):00"
//hourlabel.font = UIFont(name: "Avenir-Claro", size: 12)
hourlabel.textColor = UIColor.blue
hourlabel.textAlignment = NSTextAlignment.right
self.addSubview(hourlabel)
hourLabels.append(hourlabel)
}
}
}
More generally, you can just list all the subviews/sublayers and not keep them in separate containers.
I spent a bit more time with your question since my first thought was wrong. Let me start by saying that your approach here is not the right way to go about this. But it looks to me like you're playing with different aspects of the framework just to learn your way around and I can respect that. I spent many years working on as vector drawing program (Macromedia FreeHand) and even wrote a book about drawing with Quartz 2D back in 2006 so I understand the desire to draw it yourself.
I've reworked your example using "raw" drawing at the CGContext level. I was playing with your code in a Playground so I restructured the view creation a bit too (just so it shows up in the Playground nicely). You should be able to copy and paste this into an iOS playground and see the results.
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
class ViewController: UIViewController {
let gesto = UIPanGestureRecognizer()
let timelineView = TimelineView()
override func viewDidLoad() {
super.viewDidLoad()
view.translatesAutoresizingMaskIntoConstraints = true
self.view.backgroundColor = UIColor.red
self.view.bounds = CGRect(x:0, y:0, width: 320, height: 700)
// ===== Add TimelineView Object to view
view.addSubview(timelineView)
timelineView.frame = CGRect(x: 20, y: 20, width: 280, height: 660)
timelineView.backgroundColor = UIColor.white
debugPrint(timelineView.bounds)
// ===== ADD TOUCH GESTURE =====
gesto.addTarget(self, action: #selector(touchinput))
timelineView.addGestureRecognizer(gesto)
}
var touchStartLocation: Int = 0
var scrollDistance: Int = 0
var lastScrollDistance: Int = 0
//The following func calculates the distance scrolled/travelled by Touch gesture on the YAxis and sends the result value (scrollDistance) to the Timeline mechanism where it defines the Yposition of every UIBezier. Thanks to Mitchell Hudson on Youtube for helping me figure out how to do it on his Tutorial "06 11 touches value"
#objc func touchinput (sender: UIPanGestureRecognizer) {
if sender.state == UIGestureRecognizer.State.began {
touchStartLocation = Int(sender.location(in: view).y)
lastScrollDistance = scrollDistance
}
if sender.state == UIGestureRecognizer.State.changed {
let touchEndLocation = Int(sender.location(in: view).y)
let currentScrollDistance = touchEndLocation - touchStartLocation
print("deltaY", currentScrollDistance)
scrollDistance = lastScrollDistance + currentScrollDistance
timelineView.totalScrollDistance = scrollDistance //send scrollValue to TimelineView
}
if sender.state == UIGestureRecognizer.State.ended {
print("lastScrollDistance", lastScrollDistance)
print("scroll Distance", scrollDistance)
}
}
}
//Created a new View with the TimeLine mechanism
class TimelineView: UIView {
var totalScrollDistance: Int = 0 {
didSet {
setNeedsDisplay() //this gets called everytime UIgesture position changes
}
}
override func draw(_ rect: CGRect) {
if let cgContext = UIGraphicsGetCurrentContext() {
drawTimeline(cgContext: cgContext)
}
}
func drawTimeline(cgContext: CGContext) {
let numElements = 10
let spacing = 30
let scrollDistance = totalScrollDistance
for n in 0..<numElements {
let yPosition = n * spacing + scrollDistance
cgContext.saveGState()
cgContext.setLineWidth(1.0)
cgContext.setStrokeColor(UIColor.blue.cgColor)
cgContext.move(to: CGPoint(x: 60, y: yPosition))
cgContext.addLine(to: CGPoint(x: 300, y: yPosition))
cgContext.strokePath()
let label : NSString = "\(n):00" as NSString
label.draw(at: CGPoint(x: 5, y: yPosition - 20),
withAttributes: [.foregroundColor : UIColor.blue])
cgContext.restoreGState()
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = ViewController()
The drawRect of the custom view grabs the current CGContext and passes it to the routine that does the drawing. Using something like UIBezierPath will work, of course (you saw that it did) but it has overhead (creating an actual object, copying the object into the context graphics state on each drawing, etc) that you don't necessarily need.
I'm not sure what you were doing with CAShapeLayer. You'd typically use that if you had a shape that you want to animate around the screen. I suppose you felt that, in scrolling, you might want to do that. But again this is something where you'd want to create the shape layer outside of the drawing path, keep ahold of it, manipulate it outside of the drawing path, then let the system handle worry about putting it on the screen appriopriately.
Your instincts on text are pretty good. You really don't want to handle Text drawing yourself in a system as complex as iOS. There's Unicode issues, glyph substitution, positional forms, ligatures, bi-di text... a whole host of challenges for drawing text on iOS that it's best to leave to things like UILabel. But you want to keep building your view hierarchy separate from drawing in your view hierarchy. drawRect can be called any time even a pixel of your view needs to be redrawn and adding a new subview each time is not the best way to go. In my reworked example, I'm drawing the text using NSString - It's still not the "right" way to do it but it's fairly low level while still giving the framework a chance to do some of the text handling.
In the end you would want to work with the frameworks instead of against them. You'd want to use something like UIScrollView because it will handle a thousand details (bouncing at the boundaries, ease-in/ease-out animation, touch point tracking, fast and slow scrolling, etc) but for a learning experience your code is just fine and I hope you enjoy working with iOS more!

UIView incorrect frame on #3x devices

I have a really weird bug on #3x devices where a UIView is visually incorrectly sized.
I insist on the visually word because when I print out the frame of that particular view, everything is correct.
On #2x devices, everything looks fine.
My view hierarchy is really simple. I have a view (view B) inside another one (view A).
View B is centered in its superview (view A).
Really simple right?
Frame of view B should be:
CGRect(x: 8.5, y: 15.5, width: 19.0, height: 5.0)
If we scale it with a factor of 3 (to obtain its real dimensions on #3x devices), we should have a rectangle with the following frame:
CGRect(x: 25.5, y: 46.5, width: 57.0, height: 15.0)
When testing on a #3x device, visually, the origin.x of the view is 26px (instead of 25.5), and its width is 56px (instead of 57px).
Here is the code:
class ViewController: UIViewController {
private static let centeredViewSize = CGSize(width: 36, height: 36)
private let centeredView = UIView(frame: .zero)
private let rectangleView = UIView(frame: .zero)
// MARK: - View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
self.rectangleView.clipsToBounds = true
self.rectangleView.backgroundColor = UIColor.purple
self.centeredView.backgroundColor = UIColor.red
self.centeredView.addSubview(self.rectangleView)
self.view.addSubview(self.centeredView)
}
// MARK: - Layout
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
self.centeredView.frame.size = ViewController.centeredViewSize
self.centeredView.center = self.view.center
let rectangleViewSize = CGSize(width: 19, height: 5)
let rectangleViewHorizontalOrigin = self.centeredView.bounds.midX - (rectangleViewSize.width / 2)
let rectangleViewVerticalOrigin = self.centeredView.bounds.midY - (rectangleViewSize.height / 2)
let rectangleViewOrigin = CGPoint(x: rectangleViewHorizontalOrigin, y: rectangleViewVerticalOrigin)
self.rectangleView.frame = CGRect(origin: rectangleViewOrigin, size: rectangleViewSize)
}
}
This whole issue seems to come from the horizontal origin.
If I round it like this:
let rectangleViewHorizontalOrigin = (self.centeredView.bounds.midX - (rectangleViewSize.width / 2)).rounded()
The issue is gone. But that's not a solution. I want that view to be perfectly centered in its superview.
How can I fix this?
I created a demo project so you can try it out.
I run your code. Your frames are perfectly centred at any device.
Only two things I've changed.
1st in:
override func viewDidLoad() {
super.viewDidLoad()
// Change the order of adding subviews
self.view.addSubview(self.centeredView)
self.centeredView.addSubview(self.rectangleView)
}
2nd:
I use override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
}
instead override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
}
If something will go wrong, please edit your question with new screenshots.
Here is my console output.

How to set a border only for one of the edges of UIView

In swift, is there a way to only set a border for top side of a UIView ?
There are many ways, but drawing the border yourself might offer a little more control. I would recommend subclassing a UIView and use a CAShapeLayer to accomplish this.
Something to the effect of (using Swift 3):
import UIKit
class TopBorderedView: UIView {
//decalare a private topBorder
fileprivate weak var topBorder: CAShapeLayer?
//declare a border thickness to allow outside access to setting it
var topThickness: CGFloat = 1.0 {
didSet {
drawTopBorder()
}
}
//declare public color to allow outside access
var topColor: UIColor = UIColor.lightGray {
didSet {
drawTopBorder()
}
}
//implment the draw method
fileprivate func drawTopBorder() {
let start = CGPoint(x: 0, y: 0)
let end = CGPoint(x: bounds.width, y: 0)
removeIfNeeded(topBorder)
topBorder = addBorder(from: start, to: end, color: topColor, thickness: topThickness)
}
//implement a private border drawing method that could be used for border on other sides if desired, etc..
fileprivate func addBorder(from: CGPoint, to: CGPoint, color: UIColor, thickness: CGFloat) -> CAShapeLayer {
let border = CAShapeLayer()
let path = UIBezierPath()
path.move(to: start)
path.addLine(to: end)
border.path = path.cgPath
border.strokeColor = color.cgColor
border.lineWidth = thickness
border.fillColor = nil
layer.addSublayer(border)
return border
}
//used to remove the border and make room for a redraw to be autolayout friendly
fileprivate func removeIfNeeded(_ border: CAShapeLayer?) {
if let bdr = border {
bdr.removeFromSuperlayer()
}
}
//override layoutSubviews() (probably debatable) and call the drawTopBorder method to draw and redraw if needed
override func layoutSubviews() {
super.layoutSubviews()
drawTopBorder()
}
}
For maximum reusability in the context of storyboards - I would also take a look at using #IBDesignable and #IBInspectable for common UI patterns like this. For a decent intro, checkout NSHipster: IBDesignable and IBInspectable

Creating a pie chart in swift

I'm trying to create a pie chart in swift, and would like to create the code from scratch rather than use a 3rd party extension.
I like the idea of it being #IBDesignable, so I started with this:
import Foundation
import UIKit
#IBDesignable class PieChart: UIView {
var data: Dictionary<String,Int>?
required init(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)!
self.contentMode = .Redraw
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
self.contentMode = .Redraw
}
override fun drawRect(rect: CGRect) {
// draw the chart in here
}
}
What I'm not sure about, is how best to get the data into the chart. Should I have something like this:
#IBOutlet weak var pieChart: PieChart!
override func viewDidLoad() {
pieChart.data = pieData
pieChart.setNeedsDisplay()
}
Or is there a better way? Presumably, there is no way to include the data in the init function?
Thanks in advance!
You could create a convenience init that includes the data, but that would only be useful if you are creating the view from code. If your view is added in the Storyboard, you will want a way to set the data after the view has been created.
It is good to look at the standard UI elements (like UIButton) for design clues. You can change properties on a UIButton and it updates without you having to call myButton.setNeedsDisplay(), so you should design your pie chart to work in the same manner.
It is fine to have a property of your view that holds the data. The view should take responsibility for redrawing itself, so define didSet for your data property and call setNeedsDisplay() there.
var data: Dictionary<String,Int>? {
didSet {
// Data changed. Redraw the view.
self.setNeedsDisplay()
}
}
Then you can simply set the data, and the pie chart will redraw:
pieChart.data = pieData
You can extend this to other properties on your pie chart. For instance, you might want to change the background color. You'd define didSet for that property as well and call setNeedsDisplay.
Note that setNeedsDisplay just sets a flag and the view will be drawn later. Multiple calls to setNeedsDisplay won't cause your view to redraw multiple times, so you can do something like:
pieChart.data = pieData
pieChart.backgroundColor = .redColor()
pieChart.draw3D = true // draw the pie chart in 3D
and the pieChart would redraw just once.
No, you cannot set the data in the init method if you have added this to a scene in a storyboard (because init(coder:) will be called).
So, yes, you could just populate the data for the pie chart in viewDidLoad.
override func viewDidLoad() {
super.viewDidLoad()
pieChart.dataPoints = ...
}
But, because this PieChart is IBDesignable, that means that you probably wanted to see a rendition of the pie chart in IB. So you can implement prepareForInterfaceBuilder in the PieChart class, supplying some sample data:
override public func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
dataPoints = ...
}
That way you can now enjoy the designable view (e.g., see a preview; other inspectable properties can be manifested) in Interface Builder. The preview is our sample data, not the data that will be shown at runtime, but it may be enough to appreciate the overall design:
And, as vacawama said, you'd want to move the setNeedsDisplay into the didSet observer for the property.
public class PieChart: UIView {
public var dataPoints: [DataPoint]? { // use whatever type that makes sense for your app, though I'd suggest an array (which is ordered) rather than a dictionary (which isn't)
didSet { setNeedsDisplay() }
}
#IBInspectable public var lineWidth: CGFloat = 2 {
didSet { setNeedsDisplay() }
}
...
}
Just in case anybody looks at this question again, I wanted to post my finished code so that it can be useful to others. Here it is:
import Foundation
import UIKit
#IBDesignable class PieChart: UIView {
var dataPoints: Dictionary<String,Double> = ["Alpha":1,"Beta":2,"Charlie":3,"Delta":4,"Echo":2.5,"Foxtrot":1.4] {
didSet { setNeedsDisplay() }
}
#IBInspectable var lineWidth: CGFloat = 1.0 {
didSet { setNeedsDisplay()
}
}
#IBInspectable var lineColor: UIColor = uicolor_normal {
didSet { setNeedsDisplay() }
}
required init(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)!
self.contentMode = .Redraw
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
self.contentMode = .Redraw
}
override func drawRect(rect: CGRect) {
// set font for labels
let fieldColor: UIColor = UIColor.darkGrayColor()
let fieldFont = uifont_piechartkey
var fieldAttributes: NSDictionary = [
NSForegroundColorAttributeName: fieldColor,
NSFontAttributeName: fieldFont!
]
// get the graphics context and prepare an inset box for the pie
let ctx = UIGraphicsGetCurrentContext()
let margin: CGFloat = lineWidth
let box0 = CGRectInset(self.bounds, margin, margin)
let keyHeight = CGFloat( ceil( Double(dataPoints.count) / 3.0) * 24 ) + 16
let side : CGFloat = min(box0.width, box0.height-keyHeight)
let box = CGRectMake((self.bounds.width-side)/2, (self.bounds.height-side-keyHeight)/2,side,side)
let radius : CGFloat = min(box.width, box.height)/2.0
// converts percentages to radians for drawing the segment
func percent_to_rad(p: Double) -> CGFloat {
let rad = CGFloat(p * 0.02 * M_PI)
return rad
}
// draws a segment
func draw_arc(start: CGFloat, end: CGFloat, color: CGColor) {
CGContextBeginPath(ctx)
CGContextMoveToPoint(ctx, box.midX, box.midY)
CGContextSetFillColorWithColor(ctx, color)
CGContextAddArc(ctx,box.midX,box.midY,radius-lineWidth/2,start,end,0)
CGContextClosePath(ctx)
CGContextFillPath(ctx)
}
// draws a key item
func draw_key(keyName: String, keyValue: Double, color: CGColor, keyX: CGFloat, keyY: CGFloat) {
CGContextBeginPath(ctx)
CGContextMoveToPoint(ctx, keyX, keyY)
CGContextSetFillColorWithColor(ctx, color)
CGContextAddArc(ctx,keyX,keyY,8,0,CGFloat(2 * M_PI),0)
CGContextClosePath(ctx)
CGContextFillPath(ctx)
keyName.drawInRect(CGRectMake(keyX + 12,keyY-8,self.bounds.width/3,16),withAttributes: fieldAttributes as? [String : AnyObject])
}
let total = Double(dataPoints.values.reduce(0, combine: +)) // the total of all values
// convert dictionary to sorted touples
let dataPointsArray = dictionary_to_sorted_array(dataPoints)
// now sort the dictionary into an Array
var start = -CGFloat(M_PI_2) // start at 0 degrees, not 90
var end: CGFloat
var i = 0
// draw all segments
for dataPoint in dataPointsArray {
end = percent_to_rad(Double( (dataPoint.value)/total) * 100 )+start
draw_arc(start,end:end,color: uicolors_chart[i%uicolors_chart.count].CGColor)
start = end
i++
}
// the key
var keyX = self.bounds.minX + 8
var keyY = self.bounds.height - keyHeight + 32
i = 0
for dataPoint in dataPointsArray {
draw_key(dataPoint.key, keyValue: dataPoint.value, color: uicolors_chart[i%uicolors_chart.count].CGColor, keyX: keyX, keyY: keyY)
if((i+1)%3 == 0) {
keyX = self.bounds.minX + 8
keyY += 24
} else {
keyX += self.bounds.width / 3
}
i++
}
}
}
This will create a pie chart, that looks something like this:
[
The other bits of code you'll need are the colours array:
let uicolor_chart_1 = UIColor.init(red: 0.0/255, green:153.0/255, blue:255.0/255, alpha:1.0) //16b
let uicolor_chart_2 = UIColor.init(red: 0.0/255, green:200.0/255, blue:120.0/255, alpha:1.0)
let uicolor_chart_3 = UIColor.init(red: 140.0/255, green:220.0/255, blue:0.0/255, alpha:1.0)
let uicolor_chart_4 = UIColor.init(red: 255.0/255, green:240.0/255, blue:0.0/255, alpha:1.0)
let uicolor_chart_5 = UIColor.init(red: 255.0/255, green:180.0/255, blue:60.0/255, alpha:1.0)
let uicolor_chart_6 = UIColor.init(red: 235.0/255, green:60.0/255, blue:150.0/255, alpha:1.0)
let uicolors_chart : [UIColor] = [uicolor_chart_1,uicolor_chart_2,uicolor_chart_3,uicolor_chart_4,uicolor_chart_5,uicolor_chart_6]
And the code to convert the dictionary to an array:
func dictionary_to_sorted_array(dict:Dictionary<String,Double>) ->Array<(key:String,value:Double)> {
var tuples: Array<(key:String,value:Double)> = Array()
let sortedKeys = (dict as NSDictionary).keysSortedByValueUsingSelector("compare:")
for key in sortedKeys {
tuples.append((key:key as! String,value:dict[key as! String]!))
}
return tuples
}

Change color of UISwitch in "off" state

I've learned that we can change the UISwitch button appearance in its "on" state,
but is it also possible to change the color of the UISwitch in the "off" state?
My solution with #swift2:
let onColor = _your_on_state_color
let offColor = _your_off_state_color
let mSwitch = UISwitch(frame: CGRect.zero)
mSwitch.on = true
/*For on state*/
mSwitch.onTintColor = onColor
/*For off state*/
mSwitch.tintColor = offColor
mSwitch.layer.cornerRadius = mSwitch.frame.height / 2.0
mSwitch.backgroundColor = offColor
mSwitch.clipsToBounds = true
Result:
Try using this
yourSwitch.backgroundColor = [UIColor whiteColor];
youSwitch.layer.cornerRadius = 16.0;
All thanks to #Barry Wyckoff.
You can use the tintColor property on the switch.
switch.tintColor = [UIColor redColor]; // the "off" color
switch.onTintColor = [UIColor greenColor]; // the "on" color
Note this requires iOS 5+
Swift IBDesignable
import UIKit
#IBDesignable
class UISwitchCustom: UISwitch {
#IBInspectable var OffTint: UIColor? {
didSet {
self.tintColor = OffTint
self.layer.cornerRadius = 16
self.backgroundColor = OffTint
}
}
}
set class in Identity inspector
change color from Attributes inspector
Output
Here's a pretty good trick: you can just reach right into the UISwitch's subview that draws its "off" background, and change its background color. This works a lot better in iOS 13 than it does in iOS 12:
if #available(iOS 13.0, *) {
self.sw.subviews.first?.subviews.first?.backgroundColor = .green
} else if #available(iOS 12.0, *) {
self.sw.subviews.first?.subviews.first?.subviews.first?.backgroundColor = .green
}
Working 100% IOS 13.0 and Swift 5.0 switch both state color set same #ios13 #swift #swift5
#IBOutlet weak var switchProfile: UISwitch!{
didSet{
switchProfile.onTintColor = .red
switchProfile.tintColor = .red
switchProfile.subviews[0].subviews[0].backgroundColor = .red
}
}
The Best way to manage background color & size of UISwitch
For now it's Swift 2.3 code
import Foundation
import UIKit
#IBDesignable
class UICustomSwitch : UISwitch {
#IBInspectable var OnColor : UIColor! = UIColor.blueColor()
#IBInspectable var OffColor : UIColor! = UIColor.grayColor()
#IBInspectable var Scale : CGFloat! = 1.0
override init(frame: CGRect) {
super.init(frame: frame)
self.setUpCustomUserInterface()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.setUpCustomUserInterface()
}
func setUpCustomUserInterface() {
//clip the background color
self.layer.cornerRadius = 16
self.layer.masksToBounds = true
//Scale down to make it smaller in look
self.transform = CGAffineTransformMakeScale(self.Scale, self.Scale);
//add target to get user interation to update user-interface accordingly
self.addTarget(self, action: #selector(UICustomSwitch.updateUI), forControlEvents: UIControlEvents.ValueChanged)
//set onTintColor : is necessary to make it colored
self.onTintColor = self.OnColor
//setup to initial state
self.updateUI()
}
//to track programatic update
override func setOn(on: Bool, animated: Bool) {
super.setOn(on, animated: true)
updateUI()
}
//Update user-interface according to on/off state
func updateUI() {
if self.on == true {
self.backgroundColor = self.OnColor
}
else {
self.backgroundColor = self.OffColor
}
}
}
Swift 5:
import UIKit
extension UISwitch {
func set(offTint color: UIColor ) {
let minSide = min(bounds.size.height, bounds.size.width)
layer.cornerRadius = minSide / 2
backgroundColor = color
tintColor = color
}
}
Should you need other switches around your app, it might be also a good idea implementing #LongPham's code inside a custom class.
As others have pointed out, for the "off" state you'll need to change the background colour as well, since the default is transparent.
class MySwitch: UISwitch {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// Setting "on" state colour
self.onTintColor = UIColor.green
// Setting "off" state colour
self.tintColor = UIColor.red
self.layer.cornerRadius = self.frame.height / 2
self.backgroundColor = UIColor.red
}
}
Swift 4 easiest and fastest way to get it in 3 steps:
// background color is the color of the background of the switch
switchControl.backgroundColor = UIColor.white.withAlphaComponent(0.9)
// tint color is the color of the border when the switch is off, use
// clear if you want it the same as the background, or different otherwise
switchControl.tintColor = UIColor.clear
// and make sure that the background color will stay in border of the switch
switchControl.layer.cornerRadius = switchControl.bounds.height / 2
If you manually change the size of the switch (e.g., by using autolayout), you will have to update the switch.layer.cornerRadius too, e.g., by overriding layoutSubviews and after calling super updating the corner radius:
override func layoutSubviews() {
super.layoutSubviews()
switchControl.layer.cornerRadius = switchControl.bounds.height / 2
}
In Swift 4+:
off state:
switch.tintColor = UIColor.blue
on state:
switch.onTintColor = UIColor.red
The UISwitch offTintColor is transparent, so whatever is behind the switch shows through. Therefore, instead of masking the background color, it suffices to draw a switch-shaped image behind the switch (this implementation assumes that the switch is positioned by autolayout):
func putColor(_ color: UIColor, behindSwitch sw: UISwitch) {
guard sw.superview != nil else {return}
let onswitch = UISwitch()
onswitch.isOn = true
let r = UIGraphicsImageRenderer(bounds:sw.bounds)
let im = r.image { ctx in
onswitch.layer.render(in: ctx.cgContext)
}.withRenderingMode(.alwaysTemplate)
let iv = UIImageView(image:im)
iv.tintColor = color
sw.superview!.insertSubview(iv, belowSubview: sw)
iv.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
iv.topAnchor.constraint(equalTo: sw.topAnchor),
iv.bottomAnchor.constraint(equalTo: sw.bottomAnchor),
iv.leadingAnchor.constraint(equalTo: sw.leadingAnchor),
iv.trailingAnchor.constraint(equalTo: sw.trailingAnchor),
])
}
[But see now my other answer.]
2020 As of Xcode 11.3.1 & Swift 5
Here's the simplest way I've found of doing setting the UISwitch off-state colour with one line of code. Writing this here since this page is what came up first when I was looking and the other answers didn't help.
This is if I wanted to set the off state to be red, and can be added to the viewDidLoad() function:
yourSwitchName.subviews[0].subviews[0].backgroundColor = UIColor.red
Note - what this is actually doing is setting the background colour of the switch. This may influence the colour of the switch in the on-state too (though for me this wasn't a problem since I wanted the on and off state to be the same colour).
A solution for this:
Simply tie in the colours with an 'if else' statement inside your IBAction. If the switch is off, colour the background red. If the switch is on, leave the background clear so your chosen 'on' colour will display properly.
This goes inside the switch IBAction.
if yourSwitch.isOn == false {
yourSwitch.subviews[0].subviews[0].backgroundColor = UIColor.red
} else {
yourSwitch.subviews[0].subviews[0].backgroundColor = UIColor.clear
}
I found some behaviour where, upon the app resuming from background, the switch background would return to clear. To remedy this problem I simply added in the following code to set the colour every time the app comes to the foreground:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillEnterForeground(_:)),
name: UIApplication.willEnterForegroundNotification,
object: nil)
}
#objc func applicationWillEnterForeground(_ notification: NSNotification) {
yourSwitch.subviews[0].subviews[0].backgroundColor = UIColor.red
yourSwitch.subviews[0].subviews[0].backgroundColor = UIColor.red
}
Seems simpler than the other answers. Hope that helps!
More safe way in Swift 3 without magical 16pt values:
class ColoredBackgroundSwitch: UISwitch {
var offTintColor: UIColor {
get {
return backgroundColor ?? UIColor.clear
}
set {
backgroundColor = newValue
}
}
override func layoutSubviews() {
super.layoutSubviews()
let minSide = min(frame.size.height, frame.size.width)
layer.cornerRadius = ceil(minSide / 2)
}
}
objective c category to use on any UISwitch in project using code or storyboard:
#import <UIKit/UIKit.h>
#interface UISwitch (SAHelper)
#property (nonatomic) IBInspectable UIColor *offTint;
#end
implementation
#import "UISwitch+SAHelper.h"
#implementation UISwitch (SAHelper)
#dynamic offTint;
- (void)setOffTint:(UIColor *)offTint {
self.tintColor = offTint; //comment this line to hide border in off state
self.layer.cornerRadius = 16;
self.backgroundColor = offTint;
}
#end
XCode 11, Swift 5
I don't prefer using subViews, cause you never know when apple gonna change the hierarchy.
so I use mask view instead.
it works with iOS 12, iOS 13
private lazy var settingSwitch: UISwitch = {
let swt: UISwitch = UISwitch()
// set border color when isOn is false
swt.tintColor = .cloudyBlueTwo
// set border color when isOn is true
swt.onTintColor = .greenishTeal
// set background color when isOn is false
swt.backgroundColor = .cloudyBlueTwo
// create a mask view to clip background over the size you expected.
let maskView = UIView(frame: swt.frame)
maskView.backgroundColor = .red
maskView.layer.cornerRadius = swt.frame.height / 2
maskView.clipsToBounds = true
swt.mask = maskView
// set the scale to your expectation, here is around height: 34, width: 21.
let scale: CGFloat = 2 / 3
swt.transform = CGAffineTransform(scaleX: scale, y: scale)
swt.addTarget(self, action: #selector(switchOnChange(_:)), for: .valueChanged)
return swt
}()
#objc
func switchOnChange(_ sender: UISwitch) {
if sender.isOn {
// set background color when isOn is true
sender.backgroundColor = .greenishTeal
} else {
// set background color when isOn is false
sender.backgroundColor = .cloudyBlueTwo
}
}
I tested on IOS 14, set background as off color and onTintColor as On and works:
uiSwitch.onTintColor = UIColor.blue
uiSwitch.backgroundColor = UIColor.red
XCode 11, Swift 4.2
Starting with Matt's solution I added it to a custom, IBDesignable control. There is a timing issue in that didMoveToSuperview() is called before the offTintColor is set that needed to be handled.
#IBDesignable public class UISwitchCustom: UISwitch {
var switchMask: UIImageView?
private var observers = [NSKeyValueObservation]()
#IBInspectable dynamic var offTintColor : UIColor! = UIColor.gray {
didSet {
switchMask?.tintColor = offTintColor
}
}
override init(frame: CGRect) {
super.init(frame: frame)
initializeObservers()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initializeObservers()
}
private func initializeObservers() {
observers.append(observe(\.isHidden, options: [.initial]) {(model, change) in
self.switchMask?.isHidden = self.isHidden
})
}
override public func didMoveToSuperview() {
addOffColorMask(offTintColor)
super.didMoveToSuperview()
}
private func addOffColorMask(_ color: UIColor) {
guard self.superview != nil else {return}
let onswitch = UISwitch()
onswitch.isOn = true
let r = UIGraphicsImageRenderer(bounds:self.bounds)
let im = r.image { ctx in
onswitch.layer.render(in: ctx.cgContext)
}.withRenderingMode(.alwaysTemplate)
let iv = UIImageView(image:im)
iv.tintColor = color
self.superview!.insertSubview(iv, belowSubview: self)
iv.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
iv.topAnchor.constraint(equalTo: self.topAnchor),
iv.bottomAnchor.constraint(equalTo: self.bottomAnchor),
iv.leadingAnchor.constraint(equalTo: self.leadingAnchor),
iv.trailingAnchor.constraint(equalTo: self.trailingAnchor),
])
switchMask = iv
switchMask?.isHidden = self.isHidden
}
}
all I finally used transform and layer.cornerRadius too.
But I have added translation to it to be center.
private func setSwitchSize() {
let iosSwitchSize = switchBlockAction.bounds.size
let requiredSwitchSize = ...
let transform = CGAffineTransform(a: requiredSwitchSize.width / iosSwitchSize.width, b: 0,
c: 0, d: requiredSwitchSize.height / iosSwitchSize.height,
tx: (requiredSwitchSize.width - iosSwitchSize.width) / 2.0,
ty: (requiredSwitchSize.height - iosSwitchSize.height) / 2.0)
switchBlockAction.layer.cornerRadius = iosSwitchSize.height / 2.0
switchBlockAction.transform = transform
}
And I did use backgroundColor and tintColor in designer.
Hope it helps.