Preamble (page down after code for the actual problem):
I have a custom UIButton class in which I replaced the ordinary UIButton isHighlighted animation behavior with this behavior:
When the user's finger is actually on the button (ie. highlighting the button), a stroked outline (square border) will appear around the button. When they are no longer touching the button, the outline disappears.
Also, I have replaced the normal isSelected behavior (background goes blue) with this:
An outline identical to the one used with isHighlighted is drawn, except it is slightly thicker and always there as long as isSelected = true, and it is not there when isSelected = false.
This works, and this is how I did it:
import UIKit
class CustomButton: UIButton {
// BUTTON TYPES:
// Gorsh, enums sure would be swell here. :T
// 1 : plus
// 2 : minus
// 3 : etc etc....
#IBInspectable
var thisButtonsType: Int = 1 { didSet { setNeedsDisplay() } }
#IBInspectable
var fillColor: UIColor = .black { didSet { setNeedsDisplay() } }
#IBInspectable
var strokeColor: UIColor = .black { didSet { setNeedsDisplay() } }
override var isHighlighted: Bool {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
let unitOfMeasure = bounds.width / 5
// draw outline in case we are selected / highlighted
if (isSelected || isHighlighted) {
let tempPath = BezierPathFactory.outline(totalWidth: bounds.width)
if (isSelected) {tempPath.lineWidth = 4.0}
strokeColor.setStroke()
tempPath.stroke()
}
// initialize path object
var path = UIBezierPath()
// get the path corresponding to our button type
switch thisButtonsType {
case 1:
path = BezierPathFactory.plus(gridSpacing: unitOfMeasure)
case 2:
path = BezierPathFactory.minus(gridSpacing: unitOfMeasure)
default: print("hewo!")
}
fillColor.setFill()
path.fill()
}
}
Once again, that code works but ...
What I would really like is if that isHighlighted border outline would gradually fade away after the touch finishes.
Note: In case you're wondering why I would do things this way... I plan to implement lots of different button types... all with different icons "printed" on them... but I want them all to follow these basic behaviors. Only a couple of them are "toggle" buttons that ever become selected, so there IS a use for the highlight fade.
I already know how to do animations... but it's just doing my head in trying to think how to do that in this context.
I wish I could get the UIButton source code.
Is there a way that the fade animation could be added relatively simply to my above code without completely changing the design pattern? Am I going to have to start using some other feature of core graphics like layers?
Thanks!
You might do the following:
Create a subclass of UIButton which adds and manages a sublayer of class CAShapeLayer for the outline the button.
Draw the outline the outline into the shape layer.
Ensure that the layer resizes with the view: Overwrite layoutSubviews of your view such that it updates the frame of the layer and updates the path for the outline.
You should observe or overwrite the state property of UIControl (which is an ancestor of UIButton) to notice the state changes.
Use property animations (CABasicAnimation) for the opacity of the shape layer to fade the outline in and out.
Related
I have a custom UITextField subclass which changes its border color when typing something in it. I'm listening for changes by calling
self.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
and then, in textFieldDidChange(_:) I'm doing:
self.layer.borderColor = UIColor(named: "testColor")?.cgColor
Where testColor is a color defined in Assets.xcassets with variants for light and dark mode. The issue is that UIColor(named: "testColor")?.cgColor seems to always return the color for the light mode.
Is this a bug in iOS 13 beta or I'm doing something wrong? There's a GitHub repo with the code which exhibits this behaviour. Run the project, switch to dark mode from XCode then start typing something in the text field.
Short answer
In this situation, you need to specify which trait collection to use to resolve the dynamic color.
self.traitCollection.performAsCurrent {
self.layer.borderColor = UIColor(named: "testColor")?.cgColor
}
or
self.layer.borderColor = UIColor(named: "testColor")?.resolvedColor(with: self.traitCollection).cgColor
Longer answer
When you call the cgColor method on a dynamic UIColor, it needs to resolve the dynamic color's value. That is done by referring to the current trait collection, UITraitCollection.current.
The current trait collection is set by UIKit when it calls your overrides of certain methods, notably:
UIView
draw()
layoutSubviews()
traitCollectionDidChange()
tintColorDidChange()
UIViewController
viewWillLayoutSubviews()
viewDidLayoutSubviews()
traitCollectionDidChange()
UIPresentationController
containerViewWillLayoutSubviews()
containerViewDidLayoutSubviews()
traitCollectionDidChange()
However, outside of overrides of those methods, the current trait collection is not necessarily set to any particular value. So, if your code is not in an override of one of those methods, and you want to resolve a dynamic color, it's your responsibility to tell us what trait collection to use.
(That's because it's possible to override the userInterfaceStyle trait of any view or view controller, so even though the device may be set to light mode, you might have a view that's in dark mode.)
You can do that by directly resolving the dynamic color, using the UIColor method resolvedColor(with:). Or use the UITraitCollection method performAsCurrent, and put your code that resolves the color inside the closure. The short answer above shows both ways.
You could also move your code into one of those methods. In this case, I think you could put it in layoutSubviews(). If you do that, it will automatically get called when the light/dark style changes, so you wouldn't need to do anything else.
Reference
WWDC 2019, Implementing Dark Mode in iOS
Starting at 19:00 I talked about how dynamic colors get resolved, and at 23:30 I presented an example of how to set a CALayer's border color to a dynamic color, just like you're doing.
You can add invisible subview on you view and it will track traitCollectionDidChange events.
example:
import UIKit
extension UIButton {
func secondaryStyle() {
backgroundColor = .clear
layer.cornerRadius = 8
layer.borderWidth = 1
layer.borderColor = UIColor(named: "borderColor")?.cgColor
moon.updateStyle = { [weak self] in
self?.secondaryStyle()
}
}
}
extension UIView {
var moon: Moon {
(viewWithTag(Moon.tag) as? Moon) ?? .make(on: self)
}
final class Moon: UIView {
static var tag: Int = .max - 1
var updateStyle: (() -> Void)?
static func make(on view: UIView) -> Moon {
let moon = Moon()
moon.tag = Moon.tag
view.addSubview(moon)
return moon
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
triggerUpdateStyleEvent()
}
func triggerUpdateStyleEvent() {
updateStyle?()
}
}
}
I am have create some custom layer and added on UIView ,i have two CAShaper Layer but i am just showing only one example here
hexBorderOtline = CAShapeLayer()
hexBorderOtline.name = "dhiru_border_hidden"
hexBorderOtline.path = path2.cgPath
hexBorderOtline.lineCap = .round
hexBorderOtline.strokeColor = inActiveBorderColor.cgColor
hexBorderOtline.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(hexBorderOtline)
I want to change it's border color when button is clicked.
func btnAction()
{
hexBorderOtline.strokeColor = activeBorderColor.cgColor
}
But this is not working , i am posting one reference image what i need to do on button click .
Your method of toggling the shape layer’s stroke color is correct:
All I’m doing is:
var enabled = false
#IBAction func didTapButton(_ sender: Any) {
enabled = !enabled
hexBorderOtline.strokeColor = enabled ? activeBorderColor.cgColor : inActiveBorderColor.cgColor
}
So, your problem rests elsewhere. For example:
Perhaps you have another shape layer that is on top of the one for which you’re changing color.
Or perhaps your ivar, is pointing to another shape layer.
Or perhaps your button isn’t hooked up to your btnAction routine (the absence of any #IBAction or #objc makes me suspect you’re not calling this routine).
It’s going to be something simple like that.
I’d suggest you add print(hexBorderOtline) where you create the CAShapeLayer and again in the btnAction and confirm that both:
You are seeing both sets of print statements; and
They are printing the same memory address associated with the shape layer (i.e. make sure they are they referring to the same shape layer).
But it’s got to be something like that. This is the correct way to change strokeColor and that will update the shape layer automatically for you. Your problem rests elsewhere.
Try this:
func btnAction()
{
hexBorderOtline.removeFromSuperlayer()
hexBorderOtline.strokeColor = activeBorderColor.cgColor
self.layer.addSublayer(hexBorderOtline)
}
Without UIView:
class ViewController: UIViewController {
var hexBorderOtline: CAShapeLayer!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
hexBorderOtline = CAShapeLayer()
hexBorderOtline.name = "dhiru_border_hidden"
hexBorderOtline.path = UIBezierPath(arcCenter: CGPoint(x:self.view.frame.size.width/2, y: self.view.frame.size.height/2), radius: self.view.frame.size.height/2, startAngle: 180, endAngle: 0.0, clockwise: true).cgPath
hexBorderOtline.lineCap = .round
hexBorderOtline.strokeColor = UIColor.gray.cgColor
hexBorderOtline.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(hexBorderOtline)
}
#IBAction func updateColor(_ sender: Any) {
hexBorderOtline.removeFromSuperlayer()
hexBorderOtline.strokeColor = UIColor.red.cgColor
self.view.layer.addSublayer(hexBorderOtline)
}
}
If you are having only 2 layers which shows active and inactive modes with colours you can create two separate layers with different stroke colour and add them as a sublayers into the main layer. The default layer colour which you want to show should be added afterwards. When the button is clicked then you need to only hide/show the sublayers and you will be good to go.
Hope this helps.
Issue
I trigger random animations on a selection of UIViews using a Timer. All animations work as intended but one where the draw method of a CALayer is called after the exit of the timer selector.
Detailed Description
For the sake of clarity and simplification, let me schematise the actions performed.
All the animations I have created so far work as intended: they are a combination of CABasicAnimations on existing subviews/sublayers or new ones added to the selected view hierarchy. They are coded as an UIView extension, so that they can be called on any views, irrespectively of the view or the view controller the view is in. Well, all work except one.
I have indeed created a custom CALayer class which consists in drawing patterns on a CALayer. In an extension of this custom class, there is a method to animate those patterns (see hereafter the code). So all in all, when I reach the step/method animate selected view and run this particular animation, here is what should happen:
a method named animatePattern is called
this method adds the custom CALayer class to the selected view and then calls the animation extension of this layer
The issue: if with all the other animations, all the drawings are performed prior to the exit of the animate selected view step/method, in that particular case, the custom CALayer class draw method is called after the exit of the performAnimation method, which in turn results in the crash of the animation.
I should add that I have tried the custom CALayer class animation in a separate and simplified playground and it works well (I add the custom layer to a UIView in the UIViewController's viewDidLoad method and then I call the layer animation in the UIViewController's viewDidAppear method.)
The code
the method called by animate selected view step/method:
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
patternLayer.play(for: duration)
}
(note that this method is in a UIView extension, therefore self here represents the UIView on which the animation has been called)
the simplified custom CALayer Class:
override var bounds: CGRect {
didSet {
self.setNeedsDisplay()
}
// Initializers
override init() {
super.init()
self.setNeedsDisplay()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(effectType: EffectType){
super.init()
// sets various properties
self.setNeedsDisplay()
}
// Drawing
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
guard self.frame != CGRect.zero else { return }
self.masksToBounds = true
// do the drawings
}
the animation extension of the custom CALayer class:
func play(for duration: Double, removeAfterCompletion: RemoveAfterCompletion = .no) {
guard self.bounds != CGRect.zero else {
print("Cannot animate nil layer")
return }
CATransaction.begin()
CATransaction.setCompletionBlock {
if removeAfterCompletion.removeStatus {
if case RemoveAfterCompletion.yes = removeAfterCompletion { self.fadeOutDuration = 0.0 }
self.fadeToDisappear(duration: &self.fadeOutDuration)
}
}
// perform animations
CATransaction.commit()
}
Attempts so far
I have tried to force draw the layer by inserting setNeedsDisplay / setNeedsLayout at various places in the code but it does not work: debugging the custom CALayer class's draw method is constantly reached after the exit of the performAnimation method, whilst it should be called when the layer's frame is modified in the animatePattern method. I must miss something quite obvious but I am currently running in circles and I'd appreciate a fresh pair of eyes on it.
Thank you in advance for taking the time to consider this issue!
Best,
You override the draw(_ context) as the UIView can be the delegate of CALayer.
UIView: {
var patternLayer : PatternLayer
func animatePattern(for duration: TimeInterval = 1) {
patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
}
func draw(_ layer: CALayer, in ctx: CGContext){
layer.draw(ctx)
if NECESSARY { patternLayer.play(for: duration)}
}
}
As it is frequently the case, when you take the time to write down your issue, new ideas start to pop up. In fact, I remembered that self.setNeedsDisplay() informs the system that the layer needs to be (re)drawn but it does not (re)draw it at once: it will be done during the next refresh. It seems then that the refresh occurs after the end of the cycle for that specific animation. In order to overcome this is issue, I first added a call for the display method right after the patternLayer bounds are set, and it worked, but given #Matt comment, I changed the solution by calling displayIfNeeded method instead. It works as well.
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
// asks the system to draw the layer now
patternLayer.displayIfNeeded()
patternLayer.play(for: duration)
}
Again, should anyone come up with another more elegant solution/explanation, please do not refrain to share!
For an example if i have multiple images on views in random position. Images are selected by drawing lines on it and group images by using gestures. Right now i can able to show images randomly but not able group images by drawing line on it.
Here screenshot 1 is result which i have getting now:
screenshot 2 which is exactly what i want.
For what you are trying to do I would start by creating a custom view (a subclass) that is able to handle gestures and draw paths.
For gesture recognizer I would use UIPanGestureRecognizer. What you do is have an array of points where the gesture was handled which are then used to draw the path:
private var currentPathPoints: [CGPoint] = []
#objc private func onPan(_ sender: UIGestureRecognizer) {
switch sender.state {
case .began: currentPathPoints = [sender.location(in: self)] // Reset current array by only showing a current point. User just started his path
case .changed: currentPathPoints.append(sender.location(in: self)) // Just append a new point
case .cancelled, .ended: endPath() // Will need to report that user lifted his finger
default: break // These extra states are her just to annoy us
}
}
So if this method is used by pan gesture recognizer it should track points where user is dragging. Now these are best drawn in drawRect which needs to be overridden in your view like:
override func draw(_ rect: CGRect) {
super.draw(rect)
// Generate path
let path: UIBezierPath = {
let path = UIBezierPath()
var pointsToDistribute = currentPathPoints
if let first = pointsToDistribute.first {
path.move(to: first)
pointsToDistribute.remove(at: 0)
}
pointsToDistribute.forEach { point in
path.addLine(to: point)
}
return path
}()
let color = UIColor.red // TODO: user your true color
color.setStroke()
path.lineWidth = 3.0
path.stroke()
}
Now this method will be called when you invalidate drawing by calling setNeedsDisplay. In your case that is best done on setter of your path points:
private var currentPathPoints: [CGPoint] = [] {
didSet {
setNeedsDisplay()
}
}
Since this view should be as an overlay to your whole scene you need some way to reporting the events back. A delegate procedure should be created that implements methods like:
func endPath() {
delegate?.myLineView(self, finishedPath: currentPathPoints)
}
So now if view controller is a delegate it can check which image views were selected within the path. For first version it should be enough to just check if any of the points is within any of the image views:
func myLineView(sender: MyLineView, finishedPath pathPoints: [CGPoint]) {
let convertedPoints: [CGPoint] = pathPoints.map { sender.convert($0, to: viewThatContainsImages) }
let imageViewsHitByPath = allImageViews.filter { imageView in
return convertedPoints.contains(where: { imageView.frame.contains($0) })
}
// Use imageViewsHitByPath
}
Now after this basic implementation you can start playing by drawing a nicer line (curved) and with cases where you don't check if a point is inside image view but rather if a line between any 2 neighbor points intersects your image view.
i need to make my custom buttom which is made in cg to be darker when user pressed it, just like original uibutton highlighted state. I came up with making alpha 50% but then background view is visible and i don't want that. Here is my code for that:
func unhighlightButton(){
self.color = someColor
self.setNeedsDisplay()
}
override var isHighlighted: Bool {
didSet{
if isHighlighted == true{
self.gradientColor = someColor.withAlphaComponent(0.5)
self.setNeedsDisplay()
self.perform(#selector(unhighlightButton), with: nil, afterDelay: 0.1)
}
}
}