I have a custom hexagon shaped button and I want to disable the background color so it wont create a square shape around the hexagon itself.
I am using the backgroundRect(forBounds:) method to override the background's rect.
Here is the full implementation:
class HexaButton : UIButton {
var shape : CAShapeLayer!
func setup() {
if shape == nil {
shape = CAShapeLayer()
shape.fillColor = self.backgroundColor?.cgColor
shape.strokeColor = UIColor.cyan.cgColor
shape.lineWidth = 5.0
self.backgroundColor = UIColor.yellow
self.layer.addSublayer(shape)
}
let side : CGFloat = 6
let r = frame.height/side
shape.path = ShapeDrawer.polygonPath(x: frame.width/2, y: frame.height/2, radius: r, sides: Int(side), pointyness: side/2.0)
}
override func backgroundRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
override func layoutSubviews() {
super.layoutSubviews()
setup()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if shape?.path?.contains(point) == true {
return self
} else {
return nil
}
}
}
For some unknown reason, the backgroundRect(forBounds:) isn't being called.
Any Ideas?
You need to set the backgroundImage property of your UIButton and backgroundRect method will be called.
Related
I have been trying to create a a custom "slider" or knob with UIControl for my app. I found this tutorial and have been using it for some inspiration, but since it does not really accomplish what I want to do I am using it as more of a reference than a tutorial. Anyways, I wrote the code below and found that my CAShapeLayer was not in the center of the UIView that I set to be an instance of my custom class CircularSelector.
Here is my code:
class CircularSelector: UIControl {
private let renderer = CircularSelectorRenderer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
renderer.updateBounds(bounds)
//layer.borderWidth = 4
// layer.borderColor = UIColor.red.cgColor
layer.addSublayer(renderer.selectorLayer)
}
}
class CircularSelectorRenderer {
let selectorLayer = CAShapeLayer()
init() {
selectorLayer.fillColor = UIColor.clear.cgColor
selectorLayer.strokeColor = UIColor.white.cgColor
//Testing
//selectorLayer.borderColor = UIColor.white.cgColor
// selectorLayer.borderWidth = 4
}
private func updateSelectorLayerPath() {
let bounds = selectorLayer.bounds
let arcCenter = CGPoint(x: bounds.midX, y: bounds.maxY)
let radius = 125
var ring = UIBezierPath(arcCenter: arcCenter, radius: CGFloat(radius), startAngle: 0.degreesToRadians, endAngle: 180.degreesToRadians, clockwise: false)
selectorLayer.lineWidth = 10
selectorLayer.path = ring.cgPath
}
func updateBounds(_ bounds: CGRect) {
selectorLayer.bounds = bounds
selectorLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
updateSelectorLayerPath()
}
}
This is what I get:
and when I uncomment the code under the //Testing line in the init() of the CircularSelectorRenderer I get this:
The grey UIView is of the class CircularSelector. I am confused as to why my CAShapeLayer is wider than the grey view itself and why it is not in the center of the grey view. Why is this happening and how can I fix it.
The problem is that you are updating the shape in the wrong place. Views can (and will) change sizes for different device sizes, superview sizes, device rotation, etc.
You want to update your shape layer when your view is told to layout its subviews:
class CircularSelector: UIControl {
private let renderer = CircularSelectorRenderer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
// no need to call this here
//renderer.updateBounds(bounds)
//layer.borderWidth = 4
// layer.borderColor = UIColor.red.cgColor
layer.addSublayer(renderer.selectorLayer)
}
// add this func
override func layoutSubviews() {
super.layoutSubviews()
// here is where you want to update the layer
renderer.updateBounds(bounds)
}
}
So, I have this custom UITextField and I have two methods to add CALayer and remove the CALayer but remove is not working.
#IBDesignable class AppTextField : UITextField {
private let bottomLine = CALayer()
override func layoutSubviews() {
self.font = .systemFont(ofSize: 20)
self.addBottomLine()
self.clearButtonMode = .unlessEditing
super.layoutSubviews()
}
func removeBttomLine() {
bottomLine.removeFromSuperlayer()
}
private func addBottomLine() {
bottomLine.frame = CGRect(origin: CGPoint(x: 0, y: self.frame.height + 4), size: CGSize(width: self.frame.width, height: 1))
bottomLine.backgroundColor = UIColor.init(hexString: "#DCCFCA")?.cgColor
self.borderStyle = .none
self.layer.addSublayer(bottomLine)
}
}
The only thing you should do in layoutSubviews() is update frames as necessary.
This will show the red line when the field is NOT being edited, and will remove it while the field IS being edited:
#IBDesignable class AppTextField : UITextField {
private let bottomLine = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
self.font = .systemFont(ofSize: 20)
self.backgroundColor = .white
self.clearButtonMode = .unlessEditing
self.borderStyle = .none
bottomLine.backgroundColor = UIColor.red.cgColor
addBottomLine()
}
override func layoutSubviews() {
super.layoutSubviews()
var r = bounds
r.origin.y = bounds.maxY
r.size.height = 4.0
bottomLine.frame = r
}
func removeBottomLine() {
bottomLine.removeFromSuperlayer()
}
private func addBottomLine() {
self.layer.addSublayer(bottomLine)
}
override func resignFirstResponder() -> Bool {
super.resignFirstResponder()
addBottomLine()
return true
}
override func becomeFirstResponder() -> Bool {
super.becomeFirstResponder()
removeBottomLine()
return true
}
}
The reason could because the layoutSubviews method gets called multiple times and the layer is getting added multiple times. Try moving the addBottomLine method to required init?(coder:) method if you're using storyboard or use init(frame:) or custom init whichever gets called just once. Here's an example:
#IBDesignable class AppTextField : UITextField {
required init?(coder: NSCoder) {
super.init(coder: coder)
addBottomLine()
}
}
You're doing 3 things in your add method:
adusting frame
setting color
adding as sublayer
And because you're calling it from layoutSubviews it's called multiple times and you're ending with multiple layers added and that's why calling remove doesn't seem to work.
To make this code work you should move adding part to init (withCoder, withFrame or both). You can join it with setting color because it can be done once. Next part is adjusting frame in layoutSubviews which is required because layers can't into autolayout. At the end you will have creation, adding as sublayer and set part called once at init, and adjusting called multiple times on layout pass. Now remove when called once - it would work with visible effect this time.
I have a custom UIView called CircleView which is essentially a colored ellipse. The color property I'm using to color the ellipse is rendered using setFillColor on the graphics context. I was wondering if there was a way to animate the color change, because when I run through the animate / transition the color changes immediately instead of being animated.
Example Setup
let c = CircleView()
c.frame = CGRect(x: 20, y: 20, width: 100, height: 100)
c.color = UIColor.blue
c.backgroundColor = UIColor.clear
self.view.addSubview(c)
UIView.transition(with: c, duration: 5, options: .transitionCrossDissolve, animations: {
c.color = UIColor.red // Not animated
})
UIView.animate(withDuration: 5) {
c.color = UIColor.yellow // Not animated
}
Circle View
class CircleView : UIView {
var color = UIColor.blue {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {return}
context.addEllipse(in: rect)
context.setFillColor(color.cgColor)
context.fillPath()
}
}
You can use the built in animation support for the layer's backgroundColor.
While the easiest way to make a circle is to make your view a square (using aspect ratio constraints, for instance) and then set the cornerRadius to half the width or height, I assume you want something a bit more advanced, and that is why you used a path.
My solution to this would be something like:
class CircleView : UIView {
var color = UIColor.blue {
didSet {
layer.backgroundColor = color.cgColor
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
// Setup the view, by setting a mask and setting the initial color
private func setup(){
layer.mask = shape
layer.backgroundColor = color.cgColor
}
// Change the path in case our view changes it's size
override func layoutSubviews() {
super.layoutSubviews()
let path = CGMutablePath()
// add an elipse, or what ever path/shapes you want
path.addEllipse(in: bounds)
// Created an inverted path to use as a mask on the view's layer
shape.path = UIBezierPath(cgPath: path).reversing().cgPath
}
// this is our shape
private var shape = CAShapeLayer()
}
Or if you really need a simple circle, just something like:
class CircleView : UIView {
var color = UIColor.blue {
didSet {
layer.backgroundColor = color.cgColor
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
private func setup(){
clipsToBounds = true
layer.backgroundColor = color.cgColor
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
}
Either way, this will animate nicely:
UIView.animate(withDuration: 5) {
self.circle.color = .red
}
Strange things happens!
Your code is ok, you just need to call your animation in another method and asyncronusly
As you can see, with
let c = CircleView()
override func viewDidLoad() {
super.viewDidLoad()
c.frame = CGRect(x: 20, y: 20, width: 100, height: 100)
c.color = UIColor.blue
c.backgroundColor = UIColor.clear
self.view.addSubview(c)
changeColor()
}
func changeColor(){
DispatchQueue.main.async
{
UIView.transition(with: self.c, duration: 5, options: .transitionCrossDissolve, animations: {
self.c.color = UIColor.red // Not animated
})
UIView.animate(withDuration: 5) {
self.c.color = UIColor.yellow // Not animated
}
}
}
Work as charm.
Even if you add a button that trigger the color change, when you press the button the animation will work.
I encourage you to set this method in the definition of the CircleView
func changeColor(){
DispatchQueue.main.async
{
UIView.transition(with: self, duration: 5, options: .transitionCrossDissolve, animations: {
self.color = UIColor.red
})
UIView.animate(withDuration: 5) {
self.color = UIColor.yellow
}
}
}
and call it where you want in your ViewController, simply with
c.changeColor()
I made drop tile game that tiles fall from the top of screen to one's bottom.
This game system is when you touch a tile, the tile will be hidden.
The tiles are custom class (GameTile class), but Touches Began in GameViewController didn't work.
How can I solve it?
GameTile.swift
class GameTile: UIImageView {
init(named: String, frame: CGRect) {
super.init(frame: frame)
super.image = (UIImage(named: named))
super.isUserInteractionEnabled = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class GameTileNormal: GameTile {
let namedDefault: String
var frameDefault: CGRect
let isHiddenDefault: Bool
var isUserInteractionEnabledDefault: Bool
let colorName: UIColor
init(
named: String,
frame: CGRect,
isHidden: Bool = false,
isUserInteractionEnabled: Bool = true,
color: UIColor = UIColor.blue) {
namedDefault = named
isHiddenDefault = isHidden
frameDefault = frame
isUserInteractionEnabledDefault = isUserInteractionEnabled
colorName = color
super.init(named: named, frame: frame)
super.isHidden = isHiddenDefault
super.isUserInteractionEnabled = isUserInteractionEnabledDefault
super.backgroundColor = colorName
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
GameView.swift
class GameView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = true
self.backgroundColor = (UIColor.white)
self.frame = CGRect(x:0, y:0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height)
//make tiles
let tileNormal = GameTileNormal.init(named: "clear",
frame: CGRect(x:0), y:-60, width:60, height:60),isUserInteractionEnabled: true)
self.addSubview(tileNormal)
//move tiles
moveTile(tile: tileNormal, lane: 1)
}
}
func moveTile(tile: GameTile, lane: Int) {
UIImageView.animate(withDuration: TimeInterval(2.0),
delay: 0.0,
options: .curveLinear,
animations: {
tile.frame.origin.y = UIScreen.main.bounds.size.height
}, completion: {finished in
tile.removeFromSuperview()
//make new tile
self.makeTiles(lane: lane)
})
}
GameViewController.swift
class GameViewController: UIViewController {
var gameView: GameView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.view.isUserInteractionEnabled = true
gameView = GameView.init(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 568))
self.view.addSubview(trapView)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchEvent = touches.first!
if let gameView = self.gameView {
// touchEvent.view is "gameView", not the view whose kind of class is GameTileNormal...
if let touchedGameTile = touchEvent.view as? GameTileNormal {
print("Touched normal tile")
touchEvent.view?.isHidden = true
touchEvent.view?.isUserInteractionEnabled = false
}else{
// other view
}
}
}
UPDATE
I changed how to move tiles from UIImageView.animation to Timer.
Then If I touched tiles, it didn't through after if (tile.layer.presentation()?.hitTest(location)) != nil { in touchesBegan, GameViewController.....
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchEvent = touches.first!
let location = touchEvent.location(in: touchEvent.view)
if let standardView = self.standardView {
for tile in standardView.tiles {
//breakpoint stops here
if (tile.layer.presentation()?.hitTest(location)) != nil {
//breakpoint doesn't through here
if tile is GameTileNormal {
//normal tile touched
}else{
}
break
}
}
}
}
moveTiles
makeTileTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(updateTilesPositionY(timer:)), userInfo: sendArray, repeats: true)
update tile position (drop tiles)
#objc func updateTilesPositionY(timer: Timer){
//tile info
let timerInfo:[Any] = timer.userInfo as! [Any]
let tile:GameTile = timerInfo[0] as! GameTile
let lane: Int = timerInfo[1] as! Int
//drop tile
tile.frame.origin.y = tile.frame.origin.y+1
//if tile reached on the bottom
if tile.frame.origin.y >= UIScreen.main.bounds.size.height {
if tile is GameTileNormal {
self.showGameOverView()
}
}else{
//drop tile
}
In UIImageView.animate add option .allowUserInteraction:
UIImageView.animate(withDuration: TimeInterval(2.0),
delay: 0.0,
options: [.curveLinear, .allowUserInteraction],
animations: {
tile.frame.origin.y = UIScreen.main.bounds.size.height
}, completion: {finished in
...
By default the user interaction is disallowed during animations.
UPDATE
However, to test whether the user hit a moving object, you will have a bit harder time. See for example this SO question. Basically, the UIView object does not really move, you can easily test that after firing the animation, the frame of the animated object is set straight to the end position. Just the presentation layer draws the moving view.
You will have to always go over all your moving tiles in the game and test each one if any of them has been touched (here I assume you have a reference to all the tiles in the game):
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchEvent = touches.first!
let location = touchEvent.location(in: touchEvent.view)
if let gameView = self.gameView {
for tile in tiles {
// go over all the moving objects in your scene and hit test all of them
if let touchedLayer = tile.layer.presentation()?.hitTest(location) {
// if a hittest returns a layer, it means that this tile was touched, we can handle it and break out of the loop
tile.isHidden = true
tile.isUserInteractionEnabled = false
tile.removeFromSuperview()
break
}
}
}
}
Use layer.presentationLayer to run a hitTest if that hitTest return a CALayer then you are touching that titleView, in fact this will only work if your titles are userInteractionEnabled = false
Full Code
import UIKit
class GameTile: UIImageView {
init(named: String, frame: CGRect) {
super.init(frame: frame)
super.image = (UIImage(named: named))
super.isUserInteractionEnabled = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class GameTileNormal: GameTile {
let namedDefault: String
var frameDefault: CGRect
let isHiddenDefault: Bool
var isUserInteractionEnabledDefault: Bool
let colorName: UIColor
init(
named: String,
frame: CGRect,
isHidden: Bool = false,
isUserInteractionEnabled: Bool = false,
color: UIColor = UIColor.blue) {
namedDefault = named
isHiddenDefault = isHidden
frameDefault = frame
isUserInteractionEnabledDefault = isUserInteractionEnabled
colorName = color
super.init(named: named, frame: frame)
super.isHidden = isHiddenDefault
super.isUserInteractionEnabled = isUserInteractionEnabledDefault
super.backgroundColor = colorName
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class GameView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = true
self.backgroundColor = (UIColor.white)
self.frame = CGRect(x:0, y:0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height)
//make tiles
let tileNormal = GameTileNormal.init(named: "clear",
frame: CGRect(x:0, y:-60, width:60, height:60),isUserInteractionEnabled: false)
self.addSubview(tileNormal)
//move tiles
moveTile(tile: tileNormal, lane: 1)
self.layer.borderWidth = 1
self.layer.borderColor = UIColor.blue.cgColor
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func moveTile(tile: GameTile, lane: Int) {
UIImageView.animate(withDuration: TimeInterval(10),
delay: 0.0,
options: .curveLinear,
animations: {
tile.frame.origin.y = UIScreen.main.bounds.size.height
}, completion: {finished in
tile.removeFromSuperview()
//make new tile
//self.makeTiles(lane: lane)
})
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchEvent = touches.first!
let location = touchEvent.location(in: touchEvent.view)
for tile in self.subviews {
// go over all the moving objects in your scene and hit test all of them
if tile.layer.presentation()?.hitTest(location) != nil {
// if a hittest returns a layer, it means that this tile was touched, we can handle it and break out of the loop
tile.isHidden = true
break
}
}
}
}
class ViewController: UIViewController {
var gameView: GameView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.view.isUserInteractionEnabled = true
gameView = GameView.init(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 568))
self.view.addSubview(gameView)
}
}
I made a custom UIView which is basically a colored circle. However, when I change the circle's color property, it's not updating. How can I do this?
Code
class CircleView : UIView {
var color = UIColor.blue {
didSet {
// WHAT DO I DO!?
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {return}
context.addEllipse(in: rect)
context.setFillColor(color.cgColor)
context.fillPath()
}
}
Try the following one
var color = UIColor.blue {
didSet {
setNeedsDisplay()
}
}