UICollisionBehavior detection between round subViews in square parentViews - ios

I have a square containerView with a roundImageView inside of it. The containerView is added to the UIDynamicAnimator. When the corners of the containerViews collide off of each other I need them to bounce off of the roundImageView, same as this question. Inside the the customContainerView I override collisionBoundsType ... return .ellipse but the collision is still occurs from the square and not the circle, and the views are overlapping each other.
customView:
class CustomContainerView: UIView {
override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return .ellipse
}
}
code:
var arr = [CustomContainerView]()
var animator: UIDynamicAnimator!
var gravity: UIGravityBehavior!
var collider: UICollisionBehavior!
var bouncingBehavior : UIDynamicItemBehavior!
override func viewDidLoad() {
super.viewDidLoad()
addSubViews()
addAnimatorAndBehaviors()
}
func addAnimatorAndBehaviors() {
animator = UIDynamicAnimator(referenceView: self.view)
gravity = UIGravityBehavior(items: arr)
animator.addBehavior(gravity)
collider = UICollisionBehavior(items: arr)
collider.translatesReferenceBoundsIntoBoundary = true
animator.addBehavior(collider)
bouncingBehavior = UIDynamicItemBehavior(items: arr)
bouncingBehavior.elasticity = 0.05
animator.addBehavior(bouncingBehavior)
}
func addSubViews() {
let redView = createContainerView(with: .red)
let blueView = createContainerView(with: .blue)
let yellowView = createContainerView(with: .yellow)
let purpleView = createContainerView(with: .purple)
let greenView = createContainerView(with: .green)
view.addSubview(redView)
view.addSubview(blueView)
view.addSubview(yellowView)
view.addSubview(purpleView)
view.addSubview(greenView)
arr = [redView, blueView, yellowView, purpleView, greenView]
}
func createContainerView(with color: UIColor) -> UIView {
let containerView = CustomContainerView()
containerView.backgroundColor = .brown
let size = CGSize(width: 50, height: 50)
containerView.frame.size = size
containerView.center = view.center
let roundImageView = UIImageView()
roundImageView.translatesAutoresizingMaskIntoConstraints = false
roundImageView.backgroundColor = color
containerView.addSubview(roundImageView)
roundImageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
roundImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
roundImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
roundImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
roundImageView.layer.masksToBounds = true
roundImageView.layoutIfNeeded()
roundImageView.layer.cornerRadius = roundImageView.frame.height / 2
roundImageView.layer.borderWidth = 1
roundImageView.layer.borderColor = UIColor.white.cgColor
return containerView
}

Looks like collision behavior doesn't like .ellipse type when the views are positioned exactly on top of each other.
Running your code a few times gives different results (as expected)... sometimes, all 5 views end up in a full vertical stack, other times it ends up with some overlap, and other times (after waiting a few seconds) the views settle with a couple visible and the others way below the bottom of the view - I've seen their y-positions get to > 40,000.
I made a few modifications to your code to see what's happening...
I added more views and gave each one a shape layer showing the ellipse bounds.
Then, instead of starting with them all at identical positions, I created a couple "rows" so it looks like this:
Then, on each tap, I reset the original positions and toggle the UIDynamicItemCollisionBoundsType between ellipse and rectangle, and then call addAnimatorAndBehaviors() again.
Here's how it looks on sample .ellipse run:
and on sample .rectangle run:
As we can see, the .ellipse bounds are being used.
Here's the code I used to play with this:
class CustomContainerView: UIView {
var useEllipse: Bool = false
override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return useEllipse ? .ellipse : .rectangle
}
}
class ViewController: UIViewController {
var arr = [CustomContainerView]()
var animator: UIDynamicAnimator!
var gravity: UIGravityBehavior!
var collider: UICollisionBehavior!
var bouncingBehavior : UIDynamicItemBehavior!
let infoLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
addSubViews()
// add info label
infoLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(infoLabel)
infoLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
infoLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
// add a tap recognizer to start the Animator Behaviors
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
positionViews()
}
func positionViews() -> Void {
// let's make rows of the views,
// instead of starting with them all on top of each other
// we'll do 3-views over 2-views
let w = arr[0].frame.width * 1.1
let h = arr[0].frame.height * 1.1
var x: CGFloat = 0
var y: CGFloat = 0
var idx: Int = 0
y = h
while idx < arr.count {
x = view.center.x - w
for _ in 1...3 {
if idx < arr.count {
arr[idx].center = CGPoint(x: x, y: y)
}
x += w
idx += 1
}
y += h
x = view.center.x - w * 0.5
for _ in 1...2 {
if idx < arr.count {
arr[idx].center = CGPoint(x: x, y: y)
}
x += w
idx += 1
}
y += h
}
}
#objc func gotTap(_ g: UIGestureRecognizer) -> Void {
positionViews()
arr.forEach { v in
v.useEllipse.toggle()
}
infoLabel.text = arr[0].useEllipse ? "Ellipse" : "Rectangle"
addAnimatorAndBehaviors()
}
func addAnimatorAndBehaviors() {
animator = UIDynamicAnimator(referenceView: self.view)
gravity = UIGravityBehavior(items: arr)
animator.addBehavior(gravity)
collider = UICollisionBehavior(items: arr)
collider.translatesReferenceBoundsIntoBoundary = true
animator.addBehavior(collider)
bouncingBehavior = UIDynamicItemBehavior(items: arr)
bouncingBehavior.elasticity = 0.05
animator.addBehavior(bouncingBehavior)
}
func addSubViews() {
let clrs: [UIColor] = [
.red, .green, .blue,
.purple, .orange,
.cyan, .yellow, .magenta,
.systemTeal, .systemGreen,
]
clrs.forEach { c in
let v = createContainerView(with: c)
view.addSubview(v)
arr.append(v)
}
}
func createContainerView(with color: UIColor) -> CustomContainerView {
let containerView = CustomContainerView()
containerView.backgroundColor = UIColor.brown.withAlphaComponent(0.2)
let size = CGSize(width: 50, height: 50)
containerView.frame.size = size
view.addSubview(containerView)
let roundImageView = UIImageView()
roundImageView.translatesAutoresizingMaskIntoConstraints = false
roundImageView.backgroundColor = color
containerView.addSubview(roundImageView)
roundImageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
roundImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
roundImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
roundImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
roundImageView.layer.masksToBounds = true
roundImageView.layoutIfNeeded()
roundImageView.layer.cornerRadius = roundImageView.frame.height / 2
roundImageView.layer.borderWidth = 1
roundImageView.layer.borderColor = UIColor.white.cgColor
// let's add a CAShapeLayer to show the ellipse bounds
let c = CAShapeLayer()
c.fillColor = UIColor.clear.cgColor
c.lineWidth = 1
c.strokeColor = UIColor.black.cgColor
c.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).cgPath
containerView.layer.addSublayer(c)
return containerView
}
}
Edit
Change the while loop in positionViews() to this... tap to reset and run the animation a number of times and see what happens when all the views start with the same frame:
while idx < arr.count {
x = view.center.x - w
arr[idx].center = CGPoint(x: x, y: y)
idx += 1
}
Then, use this while loop, where we start the views at the same x-position, but increment the y-position for each view (just by 0.1 points):
while idx < arr.count {
x = view.center.x - w
// increment the y position for each view -- just a tad
y += 0.1
arr[idx].center = CGPoint(x: x, y: y)
idx += 1
}
Another Edit
Worth noting, the fact that the ellipse collision bounds is round (1:1 ratio), also affects things.
If we change the size of the view frames just slightly, we get very different results.
Try it with:
let size = CGSize(width: 50.1, height: 50)
and start them all with the exact same center point:
while idx < arr.count {
x = view.center.x - w
arr[idx].center = CGPoint(x: x, y: y)
idx += 1
}
and you'll see the views spread out immediately.
One more Edit - to help visualize the differences
Here's another example - this time, I've numbered the views and set a "every 1/10th second" timer to update a label with the current center of each view.
Also added segmented controls to select collisionBoundsType and overlaying the views exactly on top of each other or offsetting them slightly:
class CustomContainerView: UIView {
var useEllipse: Bool = false
override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return useEllipse ? .ellipse : .rectangle
}
}
// extension to left-pad a string up-to length
extension RangeReplaceableCollection where Self: StringProtocol {
func paddingToLeft(upTo length: Int, using element: Element = " ") -> SubSequence {
return repeatElement(element, count: Swift.max(0, length-count)) + suffix(Swift.max(count, count-length))
}
}
class CollisionVC: UIViewController {
var arr = [CustomContainerView]()
var animator: UIDynamicAnimator!
var gravity: UIGravityBehavior!
var collider: UICollisionBehavior!
var bouncingBehavior: UIDynamicItemBehavior!
let infoLabel = UILabel()
// add segmented controls for collisionBoundsType and "Spread Layout"
let seg1 = UISegmentedControl(items: ["Ellipse", "Rectangle"])
let seg2 = UISegmentedControl(items: ["Overlaid", "Offset"])
override func viewDidLoad() {
super.viewDidLoad()
addSubViews()
[seg1, seg2, infoLabel].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
infoLabel.numberOfLines = 0
infoLabel.font = .monospacedSystemFont(ofSize: 14.0, weight: .light)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
seg1.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
seg1.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
seg2.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
seg2.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
seg1.selectedSegmentIndex = 0
seg2.selectedSegmentIndex = 0
// add a tap recognizer to start the Animator Behaviors
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
// run a Timer... every 1/10th second we'll fill the infoLabel with
// collisionBoundsType and a list of center points
// for all subviews
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
if self.animator != nil {
var s = ""
for i in 0..<self.arr.count {
let c = self.arr[i].center
let xs = String(format: "%0.2f", c.x)
let ys = String(format: "%0.2f", c.y)
s += "\n\(i) - x: \(String(xs.paddingToLeft(upTo: 7))) y: \(String(ys.paddingToLeft(upTo: 9)))"
}
s += "\nAnimator is running: " + (self.animator.isRunning ? "Yes" : "No")
self.infoLabel.text = s
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
positionViews()
}
func positionViews() -> Void {
var x: CGFloat = 0.0
var y: CGFloat = 0.0
arr.forEach { v in
v.center = CGPoint(x: view.center.x + x, y: view.safeAreaInsets.top + 100.0 + y)
// if seg2 == Overlaid, position all views exactly on top of each other
// else, Offset the x,y center of each one by 0.1 pts
// Offsetting them allows the animator to use
// "valid" collision adjustments on start
if seg2.selectedSegmentIndex == 1 {
x += 0.1
y += 0.1
}
// set collisionBoundsType
v.useEllipse = seg1.selectedSegmentIndex == 0
}
}
#objc func gotTap(_ g: UIGestureRecognizer) -> Void {
positionViews()
addAnimatorAndBehaviors()
}
func addAnimatorAndBehaviors() {
animator = UIDynamicAnimator(referenceView: self.view)
gravity = UIGravityBehavior(items: arr)
animator.addBehavior(gravity)
collider = UICollisionBehavior(items: arr)
collider.translatesReferenceBoundsIntoBoundary = true
animator.addBehavior(collider)
bouncingBehavior = UIDynamicItemBehavior(items: arr)
bouncingBehavior.elasticity = 0.05
animator.addBehavior(bouncingBehavior)
}
func addSubViews() {
let clrs: [UIColor] = [
.red, .green, UIColor(red: 1.0, green: 0.85, blue: 0.55, alpha: 1.0),
UIColor(red: 1.0, green: 0.5, blue: 1.0, alpha: 1.0), .orange,
.cyan, .yellow, .magenta,
.systemTeal, .systemGreen,
]
for (c, i) in zip(clrs, (0..<clrs.count)) {
let v = createContainerView(with: c, number: i)
view.addSubview(v)
arr.append(v)
}
}
func createContainerView(with color: UIColor, number: Int) -> CustomContainerView {
let containerView = CustomContainerView()
containerView.backgroundColor = UIColor.brown.withAlphaComponent(0.2)
let size = CGSize(width: 50, height: 50)
containerView.frame.size = size
view.addSubview(containerView)
let roundLabel = UILabel()
roundLabel.translatesAutoresizingMaskIntoConstraints = false
roundLabel.backgroundColor = color
roundLabel.text = "\(number)"
roundLabel.textAlignment = .center
containerView.addSubview(roundLabel)
roundLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
roundLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
roundLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
roundLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
roundLabel.layer.masksToBounds = true
roundLabel.layoutIfNeeded()
roundLabel.layer.cornerRadius = roundLabel.frame.height / 2
roundLabel.layer.borderWidth = 1
roundLabel.layer.borderColor = UIColor.white.cgColor
// let's add a CAShapeLayer to show the ellipse bounds
let c = CAShapeLayer()
c.fillColor = UIColor.clear.cgColor
c.lineWidth = 1
c.strokeColor = UIColor.black.cgColor
c.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).cgPath
containerView.layer.addSublayer(c)
return containerView
}
}
Worth noting: when the collisionBoundsType == .ellipse and the views start exactly on top of each other, the collision algorithm can (and usually does) end up pushing a couple views off the bottom, which puts them outside the reference system’s bounds. At that point, the algorithm continues trying to collide those views, pushing them further and further down o the Y axis.
Here is the output after letting it run for a few seconds:
Views 5, 7 and 8 are way out of bounds, and the animator is still running. Those views will continue to be pushed further and further down, presumably until we get an invalid point crash (I haven't let it run long enough to find out).
Also, because the animator ends up doing so much processing on those out-of-bounds views, the collision detection on the remaining views suffers.

Related

CAShapeLayer with different Colors

I have a CAShapeLayer based on this answer that animates along with a UISlider.
It works fine but as the shapeLayer follows along its just 1 red CAGradientLayer color. What I want is the shapeLayer to change colors based on certain points of the slider. An example is at 0.4 - 0.5 it's red, 0.7-0.8 red, 0.9-0.95 red. Those aren't actual values, the actual values will vary. I figure that any time it doesn't meet the condition to turn red it should probably just be a clear color, which will just show the black track underneath it. The result would look something like this (never mind the shape)
The red colors are based on the user scrubbing the slider and the letting go. The different positions of the slider that determine the red color is based on whatever condition. How can I do this.
UISlider
lazy var slider: UISlider = {
let s = UISlider()
s.translatesAutoresizingMaskIntoConstraints = false
s.minimumTrackTintColor = .blue
s.maximumTrackTintColor = .white
s.minimumValue = 0
s.maximumValue = 1
s.addTarget(self, action: #selector(onSliderChange), for: .valueChanged)
return s
s.addTarget(self, action: #selector(onSliderEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
return s
}()
lazy var progressView: GradientProgressView = {
let v = GradientProgressView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
#objc fileprivate func onSliderChange(_ slider: UISlider) {
let condition: Bool = // ...
let value = slider.value
progressView.setProgress(CGFloat(value), someCondition: condition, slider_X_Position: slider_X_PositionInView())
}
#objc fileprivate func onSliderEnded(_ slider: UISlider) {
let value = slider.value
progressView.resetProgress(CGFloat(value))
}
// ... progressView is the same width as the the slider
func slider_X_PositionInView() -> CGFloat {
let trackRect = slider.trackRect(forBounds: slider.bounds)
let thumbRect = slider.thumbRect(forBounds: slider.bounds,
trackRect: trackRect,
value: slider.value)
let convertedThumbRect = slider.convert(thumbRect, to: self.view)
return convertedThumbRect.midX
}
GradientProgressView:
public class GradientProgressView: UIView {
var shapeLayer: CAShapeLayer = {
// ...
}()
private var trackLayer: CAShapeLayer = {
let trackLayer = CAShapeLayer()
trackLayer.strokeColor = UIColor.black.cgColor
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
return trackLayer
}()
private var gradient: CAGradientLayer = {
let gradient = CAGradientLayer()
let redColor = UIColor.red.cgColor
gradient.colors = [redColor, redColor]
gradient.locations = [0.0, 1.0]
gradient.startPoint = CGPoint(x: 0, y: 0)
gradient.endPoint = CGPoint(x: 1, y: 0)
return gradient
}()
// ... add the above layers as subLayers to self ...
func updatePaths() { // added in layoutSubviews
let lineWidth = bounds.height / 2
trackLayer.lineWidth = lineWidth * 0.75
shapeLayer.lineWidth = lineWidth
let path = UIBezierPath()
path.move(to: CGPoint(x: bounds.minX + lineWidth / 2, y: bounds.midY))
path.addLine(to: CGPoint(x: bounds.maxX - lineWidth / 2, y: bounds.midY))
trackLayer.path = path.cgPath
shapeLayer.path = path.cgPath
gradient.frame = bounds
gradient.mask = shapeLayer
shapeLayer.duration = 1
shapeLayer.strokeStart = 0
shapeLayer.strokeEnd = 0
}
public func setProgress(_ progress: CGFloat, someCondition: Bool, slider_X_Position: CGFloat) {
// slider_X_Position might help with shapeLayer's x position for the colors ???
if someCondition {
// redColor until the user lets go
} else {
// otherwise always a clearColor
}
shapeLayer.strokeEnd = progress
}
}
public func resetProgress(_ progress: CGFloat) {
// change to clearColor after finger is lifted
}
}
To get this:
We can use a CAShapeLayer for the red "boxes" and a CALayer as a .mask on that shape layer.
To reveal / cover the boxes, we set the frame of the mask layer to a percentage of the width of the bounds.
Here's a complete example:
class StepView: UIView {
public var progress: CGFloat = 0 {
didSet {
setNeedsLayout()
}
}
public var steps: [[CGFloat]] = [[0.0, 1.0]] {
didSet {
setNeedsLayout()
}
}
public var color: UIColor = .red {
didSet {
stepLayer.fillColor = color.cgColor
}
}
private let stepLayer = CAShapeLayer()
private let maskLayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
backgroundColor = .black
layer.addSublayer(stepLayer)
stepLayer.fillColor = color.cgColor
stepLayer.mask = maskLayer
// mask layer can use any solid color
maskLayer.backgroundColor = UIColor.white.cgColor
}
override func layoutSubviews() {
super.layoutSubviews()
stepLayer.frame = bounds
let pth = UIBezierPath()
steps.forEach { pair in
// rectangle for each "percentage pair"
let w = bounds.width * (pair[1] - pair[0])
let b = UIBezierPath(rect: CGRect(x: bounds.width * pair[0], y: 0, width: w, height: bounds.height))
pth.append(b)
}
stepLayer.path = pth.cgPath
// update frame of mask layer
var r = bounds
r.size.width = bounds.width * progress
maskLayer.frame = r
}
}
class StepVC: UIViewController {
let stepView = StepView()
override func viewDidLoad() {
super.viewDidLoad()
stepView.translatesAutoresizingMaskIntoConstraints = false
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stepView)
view.addSubview(slider)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
stepView.topAnchor.constraint(equalTo: g.topAnchor, constant: 80.0),
stepView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
stepView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
stepView.heightAnchor.constraint(equalToConstant: 40.0),
slider.topAnchor.constraint(equalTo: stepView.bottomAnchor, constant: 40.0),
slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
let steps: [[CGFloat]] = [
[0.1, 0.3],
[0.4, 0.5],
[0.7, 0.8],
[0.9, 0.95],
]
stepView.steps = steps
slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
}
#objc func sliderChanged(_ sender: UISlider) {
// disable CALayer "built-in" animations
CATransaction.setDisableActions(true)
stepView.progress = CGFloat(sender.value)
CATransaction.commit()
}
}
Edit
I'm still not clear on your 0.4 - 0.8 requirement, but maybe this will help get you on your way:
Please note: this is Example Code Only!!!
struct RecordingStep {
var color: UIColor = .black
var start: Float = 0
var end: Float = 0
var layer: CALayer!
}
class StepView2: UIView {
public var progress: Float = 0 {
didSet {
// move the progress layer
progressLayer.position.x = bounds.width * CGFloat(progress)
// if we're recording
if isRecording {
let i = theSteps.count - 1
guard i > -1 else { return }
// update current "step" end
theSteps[i].end = progress
setNeedsLayout()
}
}
}
private var isRecording: Bool = false
private var theSteps: [RecordingStep] = []
private let progressLayer = CAShapeLayer()
public func startRecording(_ color: UIColor) {
// create a new "Recording Step"
var st = RecordingStep()
st.color = color
st.start = progress
st.end = progress
let l = CALayer()
l.backgroundColor = st.color.cgColor
layer.insertSublayer(l, below: progressLayer)
st.layer = l
theSteps.append(st)
isRecording = true
}
public func stopRecording() {
isRecording = false
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
backgroundColor = .black
progressLayer.lineWidth = 3
progressLayer.strokeColor = UIColor.green.cgColor
progressLayer.fillColor = UIColor.clear.cgColor
layer.addSublayer(progressLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
// only set the progessLayer frame if the bounds height has changed
if progressLayer.frame.height != bounds.height + 7.0 {
let r: CGRect = CGRect(origin: .zero, size: CGSize(width: 7.0, height: bounds.height + 7.0))
let pth = UIBezierPath(roundedRect: r, cornerRadius: 3.5)
progressLayer.frame = r
progressLayer.position = CGPoint(x: 0, y: bounds.midY)
progressLayer.path = pth.cgPath
}
theSteps.forEach { st in
let x = bounds.width * CGFloat(st.start)
let w = bounds.width * CGFloat(st.end - st.start)
let r = CGRect(x: x, y: 0.0, width: w, height: bounds.height)
st.layer.frame = r
}
}
}
class Step2VC: UIViewController {
let stepView = StepView2()
let actionButton: UIButton = {
let b = UIButton()
b.backgroundColor = .lightGray
b.setImage(UIImage(systemName: "play.fill"), for: [])
b.tintColor = .systemGreen
return b
}()
var timer: Timer!
let colors: [UIColor] = [
.red, .systemBlue, .yellow, .cyan, .magenta, .orange,
]
var colorIdx: Int = -1
var action: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
stepView.translatesAutoresizingMaskIntoConstraints = false
actionButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stepView)
view.addSubview(actionButton)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
stepView.topAnchor.constraint(equalTo: g.topAnchor, constant: 80.0),
stepView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
stepView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
stepView.heightAnchor.constraint(equalToConstant: 40.0),
actionButton.topAnchor.constraint(equalTo: stepView.bottomAnchor, constant: 40.0),
actionButton.widthAnchor.constraint(equalToConstant: 80.0),
actionButton.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
actionButton.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
}
#objc func timerFunc(_ timer: Timer) {
// don't set progress > 1.0
stepView.progress = min(stepView.progress + 0.005, 1.0)
if stepView.progress >= 1.0 {
timer.invalidate()
actionButton.isHidden = true
}
}
#objc func btnTap(_ sender: UIButton) {
switch action {
case 0:
// this will run for 15 seconds
timer = Timer.scheduledTimer(timeInterval: 0.075, target: self, selector: #selector(timerFunc(_:)), userInfo: nil, repeats: true)
stepView.stopRecording()
actionButton.setImage(UIImage(systemName: "record.circle"), for: [])
actionButton.tintColor = .red
action = 1
case 1:
colorIdx += 1
stepView.startRecording(colors[colorIdx % colors.count])
actionButton.setImage(UIImage(systemName: "stop.circle"), for: [])
actionButton.tintColor = .black
action = 2
case 2:
stepView.stopRecording()
actionButton.setImage(UIImage(systemName: "record.circle"), for: [])
actionButton.tintColor = .red
action = 1
default:
()
}
}
}
For future reference, when posting here, it's probably a good idea to fully explain what you're trying to do. Showing code you're working on is important, but if it's really only sorta related to your actual goal, it makes this process pretty difficult.

Why does the drawing path's stroke become warped with device rotation animation

What causes the path's stroke to become warped as the device rotation animation takes place? What are some ways to keep it from happening?
Just before the animation starts, the path looks like it was drawn with a calligraphy pen, then by the time the rotation finishes it looks normal again.
Drawing View
class DrawingView: UIView {
override func draw(_ rect: CGRect) {
let strokeW: CGFloat = 10
let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
let path = UIBezierPath(ovalIn: self.bounds.insetBy(dx: strokeW/2, dy: strokeW/2))
path.lineWidth = strokeW
path.stroke()
path.move(to: center)
path.addLine(to: CGPoint(x: 0, y: 0))
path.stroke()
}
}
View Controller
extension NSLayoutConstraint {
func setPriority(_ priority: UILayoutPriority) -> NSLayoutConstraint {
self.priority = priority
return self
}
}
extension UILayoutPriority {
static var high: UILayoutPriority {
return UILayoutPriority(rawValue: 751)
}
}
class ViewController: UIViewController {
var drawingView = DrawingView()
var safeArea = UILayoutGuide()
var lg = UILayoutGuide()
var wConstraint: NSLayoutConstraint!
var hConstraint: NSLayoutConstraint!
override func loadView() {
super.loadView()
drawingView.backgroundColor = .systemTeal
self.view.addSubview(drawingView)
}
override func viewDidLoad() {
super.viewDidLoad()
self.drawingView.translatesAutoresizingMaskIntoConstraints = false
applyConstraints()
updateOrientation()
}
func updateOrientation() {
if self.view.bounds.width < self.view.bounds.height {
self.wConstraint.constant = 180
self.hConstraint.constant = 320
} else {
self.wConstraint.constant = 320
self.hConstraint.constant = 180
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: {_ in
self.updateOrientation()
self.view.layoutIfNeeded()
self.drawingView.setNeedsDisplay()
})
}
func applyConstraints() {
safeArea = self.view.safeAreaLayoutGuide
self.view.addLayoutGuide(lg)
wConstraint = lg.widthAnchor.constraint(equalToConstant: 180)
hConstraint = lg.heightAnchor.constraint(equalToConstant: 320)
NSLayoutConstraint.activate([wConstraint, hConstraint])
// layout guide constraints
let lgConstraints: [NSLayoutConstraint] = ([
lg.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor),
lg.centerYAnchor.constraint(equalTo: safeArea.centerYAnchor),
lg.leadingAnchor.constraint(greaterThanOrEqualTo: safeArea.leadingAnchor, constant: 10),
lg.trailingAnchor.constraint(lessThanOrEqualTo: safeArea.trailingAnchor, constant: 10),
lg.topAnchor.constraint(greaterThanOrEqualTo: safeArea.topAnchor, constant: 10),
lg.bottomAnchor.constraint(lessThanOrEqualTo: safeArea.bottomAnchor, constant: 10),
lg.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 10).setPriority(.high),
lg.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: 10).setPriority(.high),
lg.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 10).setPriority(.high),
lg.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: 10).setPriority(.high),
])
NSLayoutConstraint.activate(lgConstraints)
// drawingView constraints
let drawingViewConstraints: [NSLayoutConstraint] = ([
drawingView.leadingAnchor.constraint(equalTo: lg.leadingAnchor),
drawingView.trailingAnchor.constraint(equalTo: lg.trailingAnchor),
drawingView.topAnchor.constraint(equalTo: lg.topAnchor),
drawingView.bottomAnchor.constraint(equalTo: lg.bottomAnchor)
])
NSLayoutConstraint.activate(drawingViewConstraints)
}
}
UIView's default contentMode property is .scaleToFill
Changing it to .scaleAspectFit prevents the stroke's width from being distorted.
I set self.drawingView.contentMode = .scaleAspectFit in loadView() from the question's example code to get this result.

IOS Step menu using swift

I would like to create the stepper menu in IOS using swift, But I'm facing some issues. Here are the issues.
1) Portrait and landscape stepper menu is not propper.
2) How to set default step position with the method below method, It's working when button clicked. But I want to set when menu loads the first time.
self.stepView.setSelectedPosition(index: 2)
3) If it reached the position last, I would like to change the color for complete path parentPathRect.
4) Progress animation CABasicAnimation is not like the progress bar, I want to show the animation.
5) It should not remove the selected position color when changing the orientation.
As per my organization rules should not use third-party frameworks.
Can anyone help me with the solution? Or is there any alternative solution for this?
Here is my code:
import UIKit
class ViewController: UIViewController, StepMenuDelegate {
#IBOutlet weak var stepView: StepView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.stepView.delegate = self;
self.stepView.titles = ["1", "2", "3"]
self.stepView.lineWidth = 8
self.stepView.offSet = 8
self.stepView.setSelectedPosition(index: 2)
}
func didSelectItem(atIndex index: NSInteger) {
print(index)
}
}
protocol StepMenuDelegate {
func didSelectItem(atIndex index: NSInteger)
}
class StepView: UIView {
var delegate : StepMenuDelegate!
var titles: [String] = [] {
didSet(values) {
setup()
setupItems()
}
}
var lineWidth: CGFloat = 8 {
didSet(values) {
setup()
}
}
var offSet: CGFloat = 8 {
didSet(values) {
self.itemOffset = offSet * 4
setup()
}
}
private var selectedIndex : NSInteger!
private var itemOffset : CGFloat = 8 {
didSet (value) {
setup()
setupItems()
}
}
private var path : UIBezierPath!
var selectedLayer : CAShapeLayer!
private var parentPathRect : CGRect!
override func awakeFromNib() {
super.awakeFromNib()
}
override func layoutSubviews() {
self.setup()
setupItems()
}
func setup() {
self.removeAllButtonsAndLayes()
let layer = CAShapeLayer()
self.parentPathRect = CGRect(origin: CGPoint(x: offSet, y: self.bounds.midY - (self.lineWidth/2) ), size: CGSize(width: self.bounds.width - (offSet * 2), height: lineWidth))
path = UIBezierPath(roundedRect: self.parentPathRect, cornerRadius: 2)
layer.path = path.cgPath
layer.fillColor = UIColor.orange.cgColor
layer.lineCap = .butt
layer.shadowColor = UIColor.darkGray.cgColor
layer.shadowOffset = CGSize(width: 1, height: 2)
layer.shadowOpacity = 0.1
layer.shadowRadius = 2
self.layer.addSublayer(layer)
}
func setupItems() {
removeAllButtonsAndLayes()
let itemRect = CGRect(x: self.itemOffset, y: 0, width: 34, height: 34)
let totalWidth = self.bounds.width
let itemWidth = totalWidth / CGFloat(self.titles.count);
for i in 0..<self.titles.count {
let button = UIButton()
var xPos: CGFloat = itemOffset
self.addSubview(button)
xPos += (CGFloat(i) * itemWidth);
xPos += itemOffset/3
button.translatesAutoresizingMaskIntoConstraints = false
button.leftAnchor.constraint(equalTo: self.leftAnchor, constant: xPos).isActive = true
button.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 0).isActive = true
button.heightAnchor.constraint(equalToConstant: itemRect.height).isActive = true
button.widthAnchor.constraint(equalToConstant: itemRect.width).isActive = true
button.backgroundColor = UIColor.red
button.layer.zPosition = 1
button.layer.cornerRadius = itemRect.height/2
let name : String = self.titles[i]
button.tag = i
button.setTitle(name, for: .normal)
button.addTarget(self, action: #selector(selectedItemEvent(sender:)), for: .touchUpInside)
if self.selectedIndex != nil {
if button.tag == self.selectedIndex {
selectedItemEvent(sender: button)
}
}
}
}
#objc func selectedItemEvent(sender:UIButton) {
if self.selectedLayer != nil {
selectedLayer.removeFromSuperlayer()
}
self.delegate.didSelectItem(atIndex: sender.tag)
let fromRect = self.parentPathRect.origin
self.selectedLayer = CAShapeLayer()
let rect = CGRect(origin: fromRect, size: CGSize(width:sender.frame.origin.x - 4, height: 8))
let path = UIBezierPath(roundedRect: rect, cornerRadius: 4)
self.selectedLayer.path = path.cgPath
self.selectedLayer.lineCap = .round
self.selectedLayer.fillColor = UIColor.orange.cgColor
let animation = CABasicAnimation(keyPath: "fillColor")
animation.toValue = UIColor.blue.cgColor
animation.duration = 0.2
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
self.selectedLayer.add(animation, forKey: "fillColor")
self.layer.addSublayer(self.selectedLayer)
}
func removeAllButtonsAndLayes() {
for button in self.subviews {
if button is UIButton {
button.removeFromSuperview()
}
}
}
func setSelectedPosition(index:NSInteger) {
self.selectedIndex = index
}
}
Here I found One way to achieve the solution.!!
https://gist.github.com/DamodarGit/7f0f484708f60c996772ae28e5e1c615
Welcome to suggestions or code changes.!!

Programmatically created UIView's frame is 0

Cannot access uiview's frame after setting it's layout. Frame, and center is given as 0,0 points.
I should also mention that there are no storyboard's in this project. All views and everything are created programmatically.
I have created a UIView resultView programmatically and added it as a subview in scrollView, which is also added as a subview of view, then set it's constraints, anchors in a method called setupLayout() I call setupLayout() in viewDidLoad() and after that method, I call another method called configureShapeLayer(). Inside configureShapeLayer() I try to access my view's center as:
let center = resultView.center // should give resultView's center but gives 0
Then by using this center value I try to add two bezierPaths to have a status bar kind of view. But since resultView's center is not updated at that point, it appears as misplaced. Please see the pic below:
I also tried calling setupLayout() in loadView() then calling configureShapeLayer() in viewDidLoad() but nothing changed.
So I need a way to make sure all views are set in my view, all constraints, and layouts are applied before calling configureShapeLayer(). But how can I do this?
I also tried calling configureShapeLayer() in both viewWillLayoutSubviews() and viewDidLayoutSubviews() methods but it made it worse, and didnt work either.
Whole View Controller File is given below: First views are declared, then they are added into the view in prepareUI(), at the end of prepareUI(), another method setupLayout() is called. After it completes setting layout, as can be seen from viewDidLoad, finally configureShapeLayer() method is called.
import UIKit
class TryViewController: UIViewController {
let score: CGFloat = 70
lazy var percentage: CGFloat = {
return score / 100
}()
// MARK: View Declarations
private let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.backgroundColor = .white
return scrollView
}()
private let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}()
let scoreLayer = CAShapeLayer()
let trackLayer = CAShapeLayer()
let percentageLabel: UILabel = {
let label = UILabel()
label.text = ""
label.textAlignment = .center
label.font = TextStyle.systemFont(ofSize: 50.0)
return label
}()
// This one is the one should have status bar at center.
private let resultView: UIView = {
let view = UIView()
view.backgroundColor = .purple
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
prepareUI()
configureShapeLayer()
}
private func prepareUI() {
resultView.addSubviews(views: percentageLabel)
scrollView.addSubviews(views: iconImageView,
resultView)
view.addSubviews(views: scrollView)
setupLayout()
}
private func setupLayout() {
scrollView.fillSuperview()
iconImageView.anchor(top: scrollView.topAnchor,
padding: .init(topPadding: 26.0))
iconImageView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, multiplier: 0.31).isActive = true
iconImageView.heightAnchor.constraint(equalTo: iconImageView.widthAnchor, multiplier: 0.67).isActive = true
iconImageView.anchorCenterXToSuperview()
//percentageLabel.frame = CGRect(x: 0, y: 0, width: 105, height: 60)
//percentageLabel.center = resultView.center
percentageLabel.anchorCenterXToSuperview()
percentageLabel.anchorCenterYToSuperview()
let resultViewTopConstraintRatio: CGFloat = 0.104
resultView.anchor(top: iconImageView.bottomAnchor,
padding: .init(topPadding: (view.frame.height * resultViewTopConstraintRatio)))
resultView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, multiplier: 0.533).isActive = true
resultView.heightAnchor.constraint(equalTo: resultView.widthAnchor, multiplier: 1.0).isActive = true
resultView.anchorCenterXToSuperview()
configureShapeLayer()
}
private func configureShapeLayer() {
let endAngle = ((2 * percentage) * CGFloat.pi) - CGFloat.pi / 2
let center = resultView.center // should give resultView's center but gives 0
// Track Layer Part
let trackPath = UIBezierPath(arcCenter: center, radius: 50, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
trackLayer.path = trackPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor // to make different
trackLayer.lineWidth = 10
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
resultView.layer.addSublayer(trackLayer)
// Score Fill Part
let scorePath = UIBezierPath(arcCenter: center, radius: 50, startAngle: -CGFloat.pi / 2, endAngle: endAngle, clockwise: true)
scoreLayer.path = scorePath.cgPath
scoreLayer.strokeColor = UIColor.red.cgColor
scoreLayer.lineWidth = 10
scoreLayer.fillColor = UIColor.clear.cgColor
scoreLayer.lineCap = .round
scoreLayer.strokeEnd = 0
resultView.layer.addSublayer(scoreLayer)
}
}
You will be much better off creating a custom view. That will allow you to "automatically" update your bezier paths when the view size changes.
It also allows you to keep your drawing code away from your controller code.
Here is a simple example. It adds a button above the "resultView" - each time it's tapped it will increment the percentage by 5 (percentage starts at 5 for demonstration):
//
// PCTViewController.swift
//
// Created by Don Mag on 12/6/18.
//
import UIKit
class MyResultView: UIView {
let scoreLayer = CAShapeLayer()
let trackLayer = CAShapeLayer()
var percentage = CGFloat(0.0) {
didSet {
self.percentageLabel.text = "\(Int(percentage * 100))%"
self.setNeedsLayout()
}
}
let percentageLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = ""
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 40.0)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
layer.addSublayer(trackLayer)
layer.addSublayer(scoreLayer)
addSubview(percentageLabel)
NSLayoutConstraint.activate([
percentageLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
percentageLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
override func layoutSubviews() {
super.layoutSubviews()
let endAngle = ((2 * percentage) * CGFloat.pi) - CGFloat.pi / 2
trackLayer.frame = self.bounds
scoreLayer.frame = self.bounds
let centerPoint = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
// Track Layer Part
let trackPath = UIBezierPath(arcCenter: centerPoint, radius: 50, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
trackLayer.path = trackPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor // to make different
trackLayer.lineWidth = 10
trackLayer.fillColor = UIColor.clear.cgColor
// pre-Swift 4.2
trackLayer.lineCap = kCALineCapRound
// trackLayer.lineCap = .round
// Score Fill Part
let scorePath = UIBezierPath(arcCenter: centerPoint, radius: 50, startAngle: -CGFloat.pi / 2, endAngle: endAngle, clockwise: true)
scoreLayer.path = scorePath.cgPath
scoreLayer.strokeColor = UIColor.red.cgColor
scoreLayer.lineWidth = 10
scoreLayer.fillColor = UIColor.clear.cgColor
// pre-Swift 4.2
scoreLayer.lineCap = kCALineCapRound
// scoreLayer.lineCap = .round
}
}
class PCTViewController: UIViewController {
let resultView: MyResultView = {
let v = MyResultView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .purple
return v
}()
let btn: UIButton = {
let b = UIButton()
b.translatesAutoresizingMaskIntoConstraints = false
b.setTitle("Add 5 percent", for: .normal)
b.backgroundColor = .blue
return b
}()
var pct = 5
#objc func incrementPercent(_ sender: Any) {
pct += 5
resultView.percentage = CGFloat(pct) / 100.0
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(btn)
view.addSubview(resultView)
NSLayoutConstraint.activate([
btn.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20.0),
btn.centerXAnchor.constraint(equalTo: view.centerXAnchor),
resultView.widthAnchor.constraint(equalToConstant: 300.0),
resultView.heightAnchor.constraint(equalTo: resultView.widthAnchor),
resultView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
resultView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
btn.addTarget(self, action: #selector(incrementPercent), for: .touchUpInside)
resultView.percentage = CGFloat(pct) / 100.0
}
}
Result:
This is not the ideal fix, but try calling configureShapeLayer() func on main thread, like this:
DispatchQueue.main.async {
configureShapeLayer()
}
I had problem like that once and was something like that.

NSLayoutConsstraint constant not affecting view after UITapGestureRecognizer was tapped

i have a view (self.view) that is masked with another view (not a layer) using the
UIView.mask property. on self.view i installed a UIPanGestureRecognizer so when i pan across the screen the mask gets smaller and larger accordingly. in addition i installed on self.view a UITapGestureRecognizer which adds animatable UIImageViews to the screen and they are animating across a UIBezierPath. i am updating the mask size with constraints.
the problem is that after i tap the screen to add animatable views the changes i make on the mask constraint stop taking affect. i can see in the log that i do indeed change the constant of the constraint and that the UIPanGestureRecognizer is still working.
so i mean that the mask view constraint stop affecting its view. why is that? thanks
video illustration: https://youtu.be/UtNuc8nicgs
here is the code:
class UICircle: UIView {
init() {
super.init(frame: .zero)
self.clipsToBounds = true
self.backgroundColor = .yellow
self.isUserInteractionEnabled = false
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var diameterConstraint: NSLayoutConstraint?
var animating = false
func updateSize(_ delta: CGFloat, animated: Bool = false) {
if animating { return }
if animated {
animating = true
diameterConstraint?.constant = UIScreen.main.bounds.height * 2.1
let duration: TimeInterval = 0.6
let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animation.fromValue = self.layer.cornerRadius
animation.toValue = UIScreen.main.bounds.height * 2.1 / 2
animation.duration = duration
self.layer.add(animation, forKey: nil)
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut], animations: {
self.superview?.layoutIfNeeded()
}, completion: { (success) in
if success {
self.animating = false
}
})
} else {
let newSize = diameterConstraint!.constant + (delta * 2.85)
if newSize > 60 && newSize < UIScreen.main.bounds.height * 2.1 {
diameterConstraint?.constant = newSize
}
}
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if let superv = superview {
self.makeSquare()
self.centerHorizontallyTo(superv)
let c = NSLayoutConstraint.init(item: self, attribute: .centerY, relatedBy: .equal, toItem: superv, attribute: .bottom, multiplier: 1, constant: -40)
c.isActive = true
diameterConstraint = self.constrainHeight(superv.frame.height * 2.1)
}
}
override func layoutSubviews() {
super.layoutSubviews()
self.layer.cornerRadius = self.frame.width / 2
}
}
class ViewController: UIViewController, UIGestureRecognizerDelegate {
var circle = UICircle()
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = UIColor.init(red: 48/255, green: 242/255, blue: 194/255, alpha: 1)
self.view.clipsToBounds = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
self.view.addGestureRecognizer(tap)
setupCircle()
}
func setupCircle() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.delegate = self
self.view.addGestureRecognizer(panGesture)
self.view.mask = circle
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
var panStarted = false
func handlePan(_ pan: UIPanGestureRecognizer) {
let delta = pan.translation(in: self.view).y
if pan.state == .began {
if delta > 0 {
panStarted = true
circle.updateSize(-delta)
}
} else if pan.state == .changed {
if panStarted {
circle.updateSize(-delta)
}
} else if pan.state == .ended || pan.state == .cancelled {
if panStarted {
circle.updateSize(self.view.frame.height * 2.1, animated: true)
}
panStarted = false
}
pan.setTranslation(.zero, in: self.view)
}
func handleTap() {
let num = Int(5 + drand48() * 10)
(1 ... num).forEach { (_) in
addView()
}
}
override var prefersStatusBarHidden: Bool {
get {
return true
}
}
func addView() {
var image: UIImageView!
let dd = drand48()
if dd < 0.5 {
image = UIImageView(image: #imageLiteral(resourceName: "heart1"))
} else {
image = UIImageView(image: #imageLiteral(resourceName: "heart2"))
}
image.isUserInteractionEnabled = false
image.contentMode = .scaleAspectFit
let dim: CGFloat = 20 + CGFloat(10 * drand48())
image.constrainHeight(dim)
image.constrainWidth(dim)
let animation = CAKeyframeAnimation(keyPath: "position")
let duration = Double(1.5 * self.view.frame.width / CGFloat((60 + drand48() * 40))) // duration = way / speed
animation.path = getPath().cgPath
animation.duration = duration
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animation.fillMode = kCAFillModeForwards
animation.isRemovedOnCompletion = false
image.layer.add(animation, forKey: nil)
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + duration + 1) {
DispatchQueue.main.async {
image.removeFromSuperview()
}
}
if drand48() < 0.3 {
UIView.animate(withDuration: 0.2 + 0.1 * drand48() , delay: TimeInterval(drand48() * 1), options: [.curveEaseOut, .repeat, .autoreverse], animations: {
image.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
}, completion: nil)
}
self.view.addSubview(image)
}
func getPath() -> UIBezierPath {
let path = UIBezierPath()
let startPoint = CGPoint.init(x: -30, y: self.view.frame.height / 2)
path.move(to: startPoint)
let r = CGFloat(400 * drand48())
let cp1 = CGPoint.init(x: self.view.frame.width * 0.33, y: self.view.frame.height * 0.25 - r)
let cp2 = CGPoint.init(x: self.view.frame.width * 0.66, y: self.view.frame.height * 0.75 + r)
let endPoint = CGPoint.init(x: self.view.frame.width + 30, y: self.view.frame.height / 2)
path.addCurve(to: endPoint, controlPoint1: cp1, controlPoint2: cp2)
return path
}
}
extension UIView {
#discardableResult
func makeSquare() -> NSLayoutConstraint {
self.turnOffMaskResizing()
let constraint = NSLayoutConstraint(item: self, attribute: NSLayoutAttribute.width, relatedBy: NSLayoutRelation.equal, toItem: self, attribute: NSLayoutAttribute.height, multiplier: 1.0, constant: 0)
NSLayoutConstraint.activate([constraint])
return constraint
}
#discardableResult
func centerHorizontallyTo(_ toItem: UIView, padding: CGFloat) -> NSLayoutConstraint {
self.turnOffMaskResizing()
let constraint = NSLayoutConstraint(item: self, attribute: NSLayoutAttribute.centerX, relatedBy: NSLayoutRelation.equal, toItem: toItem, attribute: NSLayoutAttribute.centerX, multiplier: 1.0, constant: padding)
NSLayoutConstraint.activate([constraint])
return constraint
}
#discardableResult
func constrainHeight(_ height: CGFloat, priority: UILayoutPriority = 1000) -> NSLayoutConstraint {
self.turnOffMaskResizing()
let constraint = NSLayoutConstraint(item: self, attribute: NSLayoutAttribute.height, relatedBy: NSLayoutRelation.equal, toItem: nil, attribute: NSLayoutAttribute.height, multiplier: 0, constant: height)
constraint.priority = priority
NSLayoutConstraint.activate([constraint])
return constraint
}
#discardableResult
func constrainWidth(_ width: CGFloat) -> [NSLayoutConstraint] {
self.turnOffMaskResizing()
let constraints = NSLayoutConstraint.constraints(withVisualFormat: "H:[item(width)]", metrics: ["width" : width], views: ["item" : self])
NSLayoutConstraint.activate(constraints)
return constraints
}
func turnOffMaskResizing() {
self.translatesAutoresizingMaskIntoConstraints = false
}
}
I think it is because you add new objects to that view which will affect the constraints, and they break. What I propose is to add circle as a subview so it is not related to the other objects.
This is what I tried and it worked
override func viewDidLoad() {
super.viewDidLoad()
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
self.view.addGestureRecognizer(tap)
setupCircle()
}
func setupCircle() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.delegate = self
self.view.addSubview(circle)
self.circle.backgroundColor = UIColor.init(red: 48/255, green: 242/255, blue: 194/255, alpha: 1)
self.circle.clipsToBounds = true
self.view.addGestureRecognizer(panGesture)
}
EDIT:Added images of change what will happen in your hierarchy
Before tap
After tap
Your mask seems removed after the tap - But I am not sure how to fix that, still do not see reason why can't you add subview
This is proof of my concept, took and reworked CircleMaskView from https://stackoverflow.com/a/33076583/4284508. This does what you need. It is little bit mess, so do not take it as a done thing. I use your class to get frame and radius for the other mask, so you will need to get rid of it somehow and compute radius and frame in some other manner. But it will serve
/// Apply a circle mask on a target view. You can customize radius, color and opacity of the mask.
class CircleMaskView {
private var fillLayer = CAShapeLayer()
var target: UIView?
var fillColor: UIColor = UIColor.gray {
didSet {
self.fillLayer.fillColor = self.fillColor.cgColor
}
}
var radius: CGFloat? {
didSet {
self.draw()
}
}
var opacity: Float = 0.5 {
didSet {
self.fillLayer.opacity = self.opacity
}
}
/**
Constructor
- parameter drawIn: target view
- returns: object instance
*/
init(drawIn: UIView) {
self.target = drawIn
}
/**
Draw a circle mask on target view
*/
func draw() {
guard let target = target else {
print("target is nil")
return
}
var rad: CGFloat = 0
let size = target.frame.size
if let r = self.radius {
rad = r
} else {
rad = min(size.height, size.width)
}
let path = UIBezierPath(roundedRect: CGRect(x:0, y:0, width:size.width, height:size.height), cornerRadius: 0.0)
let circlePath = UIBezierPath(roundedRect: CGRect(x:size.width / 2.0 - rad / 2.0, y:0, width:rad, height:rad), cornerRadius: rad)
path.append(circlePath)
path.usesEvenOddFillRule = true
fillLayer.path = path.cgPath
fillLayer.fillRule = kCAFillRuleEvenOdd
fillLayer.fillColor = self.fillColor.cgColor
fillLayer.opacity = self.opacity
target.layer.addSublayer(fillLayer)
}
func redraw(withCircle circle: UICircle) {
guard let target = target else {
print("target is nil")
return
}
var rad: CGFloat = 0
let size = target.frame.size
if let r = self.radius {
rad = r
} else {
rad = min(size.height, size.width)
}
let path = UIBezierPath(roundedRect: CGRect(x:0, y:0, width:size.width, height:size.height), cornerRadius: 0.0)
let circlePath = UIBezierPath(roundedRect: circle.frame, cornerRadius: circle.diameterConstraint!.constant)
path.append(circlePath)
path.usesEvenOddFillRule = true
fillLayer.path = path.cgPath
fillLayer.fillRule = kCAFillRuleEvenOdd
fillLayer.fillColor = self.fillColor.cgColor
fillLayer.opacity = self.opacity
target.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
target.layer.addSublayer(fillLayer)
}
/**
Remove circle mask
*/
func remove() {
self.fillLayer.removeFromSuperlayer()
}
}
var circle = UICircle()
var circleMask: CircleMaskView?
var subviewC = UIView()
override func viewDidLoad() {
super.viewDidLoad()
self.subviewC.clipsToBounds = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
self.view.addGestureRecognizer(tap)
view.backgroundColor = UIColor.init(red: 48/255, green: 242/255, blue: 194/255, alpha: 1)
subviewC.backgroundColor = .clear
subviewC.frame = view.frame
self.view.addSubview(subviewC)
self.view.addSubview(circle)
circle.backgroundColor = .clear
setupCircle()
}
func setupCircle() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.delegate = self
self.subviewC.addGestureRecognizer(panGesture)
circleMask = CircleMaskView(drawIn: subviewC)
circleMask?.opacity = 1.0
circleMask?.draw()
}
override func viewDidLayoutSubviews() {
circleMask?.redraw(withCircle: circle)
}
func handlePan(_ pan: UIPanGestureRecognizer) {
let delta = pan.translation(in: self.view).y
if pan.state == .began {
if delta > 0 {
panStarted = true
circle.updateSize(-delta)
circleMask?.redraw(withCircle: circle)
}
} else if pan.state == .changed {
if panStarted {
circle.updateSize(-delta)
circleMask?.redraw(withCircle: circle)
}
} else if pan.state == .ended || pan.state == .cancelled {
if panStarted {
circle.updateSize(self.view.frame.height * 2.1, animated: true)
circleMask?.redraw(withCircle: circle)
}
panStarted = false
}
pan.setTranslation(.zero, in: self.view)
}

Resources