UIPanGestureRecognizer doesn't remember where the pan began - ios

I have a custom subclass of UIView, which has a UIPanGestureRecognizer and all the associated stuff it needs to handle it. The idea is that, depending on where the panning started from inside my view different things shall happen. Also I'll have to track the current position of the pan gesture later on. How do I properly set this up?
Here's what my code looks like right now:
import UIKit
#IBDesignable class RangeSelectorView: UIView {
private let trackWidth: CGFloat = 3.0
private let thumbRadius: CGFloat = 10.0
private let trackOffset: CGFloat = 16
#IBInspectable let min: Int = 0
#IBInspectable let max: Int = 100
#IBInspectable var lowerThumbValue: Int = 0
#IBInspectable var upperThumbValue: Int = 100
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
func setup() {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(trackTapped))
addGestureRecognizer(tapGestureRecognizer)
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(thumbDragged))
addGestureRecognizer(panGestureRecognizer)
}
override func draw(_ rect: CGRect) {
if let trackContext = UIGraphicsGetCurrentContext() {
trackContext.setLineWidth(trackWidth)
trackContext.setStrokeColor(UIColor.lightGray.cgColor)
trackContext.move(to: CGPoint(x: trackOffset, y: self.frame.height / 2))
trackContext.addLine(to: CGPoint(x: self.frame.width
- trackOffset, y: self.frame.height / 2))
trackContext.strokePath()
}
if let thumbContext = UIGraphicsGetCurrentContext() {
let lowerRect = CGRect(x: trackOffset + getRelativeThumbPosition(for: lowerThumbValue) - thumbRadius, y: self.frame.height / 2 - thumbRadius, width: thumbRadius * 2, height: thumbRadius * 2)
let upperRect = CGRect(x: trackOffset + getRelativeThumbPosition(for: upperThumbValue) - thumbRadius, y: self.frame.height / 2 - thumbRadius, width: thumbRadius * 2, height: thumbRadius * 2)
thumbContext.addEllipse(in: lowerRect)
thumbContext.addEllipse(in: upperRect)
thumbContext.setFillColor(Constants.Colors.titleBlue.cgColor)
thumbContext.fillPath()
}
if let highlightedRangeContext = UIGraphicsGetCurrentContext() {
highlightedRangeContext.setLineWidth(trackWidth)
highlightedRangeContext.setStrokeColor(Constants.Colors.titleBlue.cgColor)
highlightedRangeContext.move(to: CGPoint(x: trackOffset + getRelativeThumbPosition(for: lowerThumbValue) + thumbRadius, y: self.frame.height / 2))
highlightedRangeContext.addLine(to: CGPoint(x: trackOffset + getRelativeThumbPosition(for: upperThumbValue) - thumbRadius, y: self.frame.height / 2))
highlightedRangeContext.strokePath()
}
}
private func getRelativeThumbPosition(for value: Int) -> CGFloat {
let iterationDistance = (self.frame.width - trackOffset * 2) / CGFloat(max - min)
return (CGFloat(value - min) * iterationDistance)
}
private func getTrackValue(for position: CGPoint) -> Int {
let iterationDistance = (self.frame.width - trackOffset * 2) / CGFloat(max - min)
return Int(position.x / iterationDistance)
}
#objc func trackTapped(recognizer: UITapGestureRecognizer) {
var tappedValue = getTrackValue(for: recognizer.location(ofTouch: 0, in: self))
if tappedValue > max {
tappedValue = max
} else if tappedValue < min {
tappedValue = min
}
if tappedValue <= lowerThumbValue || tappedValue <= (upperThumbValue + lowerThumbValue) / 2 {
lowerThumbValue = tappedValue
} else {
upperThumbValue = tappedValue
}
setNeedsDisplay()
}
#objc func thumbDragged(recognizer: UIPanGestureRecognizer) {
if recognizer.state == .changed {
if getTrackValue(for: recognizer.location(in: self)) <= (lowerThumbValue + upperThumbValue) / 2 {
lowerThumbValue = getTrackValue(for: recognizer.translation(in: self))
} else {
upperThumbValue = getTrackValue(for: recognizer.translation(in: self))
}
}
if recognizer.state == .ended {
recognizer.setTranslation(recognizer.location(in: self), in: self)
}
setNeedsDisplay()
}
}
Everytime the function is called only the lowerThumbValue gets set. Also when a new pan gesture is started it 'hops' back to the initial value. What in detail am I doing wrong?

translation(in:) returns a relative change of the pan since the beginning, location(in:) should return a location of the touch in the coordinate system of the given view - use location(in:) to get the location in the view when the pan gesture begins.

Related

Completion block of animation is performed immediately

I'm trying to remove the custom view from the superview after the end of the animation in the completion block, but it is called immediately and the animation becomes sharp. I managed to solve the problem in a not very good way: just adding a delay to remove the view.
Here is the function for animating the view:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
}
The problem in this line: self.soundView.removeFromSuperview()
When I call this function in the switch recognizer.state completion block statement it executes early and when elsewhere everything works correctly.
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
case .changed:
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
if notHiddenSoundViewRect.minX > soundView.frame.minX {
animatedHideSoundView(toRight: false)
} else if notHiddenSoundViewRect.maxX < soundView.frame.maxX {
animatedHideSoundView(toRight: true)
}
case .ended, .cancelled:
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(
x: soundView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: soundView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestCornerPosition = nearestCorner(to: projectedPosition)
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: soundView.center.x, to: nearestCornerPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: soundView.center.y, to: nearestCornerPosition.y)
)
let timingParameters = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
animator.addAnimations {
self.soundView.center = nearestCornerPosition
}
animator.startAnimation()
default: break
}
}
I want the user to be able to swipe this soundView off the screen.
That's why I check where the soundView is while the user is moving it, so that if he moves the soundView near the edge of the screen, I can hide the soundView animatedly.
Maybe I'm doing it wrong, but I couldn't think of anything else, because I don't have much experience. Could someone give me some advice on this?
I managed to solve it this way, but I don't like it:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
enter image description here
You can see and run all code here: https://github.com/swiloper/AnimationProblem
Couple notes...
First, in your controller code, you are calling animatedHideSoundView() from your pan gesture recognizer every time you move the touch. It's unlikely that's what you want to do.
Second, if you call animatedHideSoundView(toRight: true) your code:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
}
sets translationX to Zero ... when you then try to animate the transform, the animation will take no time because you're not changing the x.
Third, I strongly suggest that you start simple. The code you linked to cannot be copy/pasted/run, which makes it difficult to offer help.
Here's a minimal version of your UniversalTypesViewController class (it uses your linked SoundView class):
final class UniversalTypesViewController: UIViewController {
// MARK: Properties
private lazy var soundView = SoundView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
private let panGestureRecognizer = UIPanGestureRecognizer()
private var initialOffset: CGPoint = .zero
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
panGestureRecognizer.addTarget(self, action: #selector(soundViewPanned(recognizer:)))
soundView.addGestureRecognizer(panGestureRecognizer)
}
private func animatedShowSoundView() {
// reset soundView's transform
soundView.transform = .identity
// add it to the view
view.addSubview(soundView)
// position soundView near bottom, but past the right side of view
soundView.frame.origin = CGPoint(x: view.frame.width, y: view.frame.height - soundView.frame.height * 2.0)
soundView.startSoundBarsAnimation()
// animate soundView into view
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseOut) {
self.soundView.transform = CGAffineTransform(translationX: -self.soundView.frame.width * 2.0, y: 0.0)
}
}
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? view.frame.width : -(view.frame.width + soundView.frame.width)
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
//self.songPlayer.pause()
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// if soundView is not in the view hierarchy,
// animate it into view - animatedShowSoundView() func adds it as a subview
if soundView.superview == nil {
animatedShowSoundView()
} else {
// unwrap the touch
guard let touch = touches.first else { return }
// get touch location
let loc = touch.location(in: self.view)
// if touch is inside the soundView frame,
// return, so pan gesture can move soundView
if soundView.frame.contains(loc) { return }
// if touch is on the left-half of the screen,
// animate soundView to the left and remove after animation
if loc.x < view.frame.midX {
animatedHideSoundView(toRight: false)
} else {
// touch is on the right-half of the screen,
// so just remove soundView
animatedHideSoundView(toRight: true)
}
}
}
// MARK: Objc methods
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
case .changed:
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
case .ended, .cancelled:
()
default: break
}
}
}
If you run that, tapping anywhere will animate soundView into view at bottom-right. You can then drag soundView around.
If you tap away from soundView frame, on the left-half of the screen, soundView will be animated out to the left and removed after animation completes.
If you tap away from soundView frame, on the right-half of the screen, soundView will be animated out to the right and removed after animation completes.
Once you've got that working, and you see what's happening, you can implement it in the rest of your much-more-complex code.
Edit
Take a look at this modified version of your code.
One big problem in your code is that you're making multiple calls to animatedHideSoundView(). When the drag gets near the edge, your code calls that... but then it gets called again because the drag is still "active."
So, I added a var isHideAnimationRunning: Bool flag so calls to positioning when dragging and positioning when "hide" animating don't conflict.
A few other changes:
instead of mixing Transforms with .center positioning, get rid of the Transforms and just use .center
I created a struct with logically named corner points - makes it much easier to reference them
strongly recommended: add comments to your code!
So, give this a try:
import UIKit
let screenWidth: CGFloat = UIScreen.main.bounds.width
let screenHeight: CGFloat = UIScreen.main.bounds.height
let sideSpacing: CGFloat = 32.0
let mediumSpacing: CGFloat = 16.0
var isNewIphone: Bool {
return screenHeight / screenWidth > 1.8
}
extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
return sqrt(pow(point.x - x, 2) + pow(point.y - y, 2))
}
}
// so we can refer to corner positions by logical names
struct CornerPoints {
var topLeft: CGPoint = .zero
var bottomLeft: CGPoint = .zero
var bottomRight: CGPoint = .zero
var topRight: CGPoint = .zero
}
final class ViewController: UIViewController {
private var cornerPoints = CornerPoints()
private let soundViewSide: CGFloat = 80.0
private lazy var halfSoundViewWidth = soundViewSide / 2
private lazy var newIphoneSpacing = isNewIphone ? mediumSpacing : 0.0
private lazy var soundView = SoundView(frame: CGRect(origin: .zero, size: CGSize(width: soundViewSide, height: soundViewSide)))
private lazy var notHiddenSoundViewRect = CGRect(x: mediumSpacing, y: 0.0, width: screenWidth - mediumSpacing * 2, height: screenHeight)
private var initialOffset: CGPoint = .zero
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .yellow
// setup corner points
let left = sideSpacing + halfSoundViewWidth
let right = view.frame.maxX - (sideSpacing + halfSoundViewWidth)
let top = sideSpacing + halfSoundViewWidth - newIphoneSpacing
let bottom = view.frame.maxY - (sideSpacing + halfSoundViewWidth - newIphoneSpacing)
cornerPoints.topLeft = CGPoint(x: left, y: top)
cornerPoints.bottomLeft = CGPoint(x: left, y: bottom)
cornerPoints.bottomRight = CGPoint(x: right, y: bottom)
cornerPoints.topRight = CGPoint(x: right, y: top)
let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: #selector(soundViewPanned(recognizer:)))
soundView.addGestureRecognizer(panGestureRecognizer)
// for development, let's add a double-tap recognizer to
// add the soundView again (if it's been removed)
let dt = UITapGestureRecognizer(target: self, action: #selector(showAgain(_:)))
dt.numberOfTapsRequired = 2
view.addGestureRecognizer(dt)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.animatedShowSoundView()
}
}
#objc func showAgain(_ f: UITapGestureRecognizer) {
// if soundView has been removed
if soundView.superview == nil {
// add it
animatedShowSoundView()
}
}
private func animatedShowSoundView() {
// start at bottom-right, off-screen to the right
let pt: CGPoint = cornerPoints.bottomRight
soundView.center = CGPoint(x: screenWidth + soundViewSide, y: pt.y)
view.addSubview(soundView)
soundView.startSoundBarsAnimation()
// animate to bottom-right corner
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseOut) {
self.soundView.center = pt
}
}
// flag so we know if soundView is currently
// "hide" animating
var isHideAnimationRunning: Bool = false
private func animatedHideSoundView(toRight: Bool) {
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
// set flag to true
isHideAnimationRunning = true
// target center X
let targetX: CGFloat = toRight ? screenWidth + soundViewSide : -soundViewSide
UIView.animate(withDuration: 0.5) {
self.soundView.center.x = targetX
} completion: { isFinished in
self.isHideAnimationRunning = false
if isFinished {
self.soundView.removeFromSuperview()
//self.songPlayer.pause()
}
}
}
}
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
}
case .changed:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
if notHiddenSoundViewRect.minX > soundView.frame.minX {
animatedHideSoundView(toRight: false)
} else if notHiddenSoundViewRect.maxX < soundView.frame.maxX {
animatedHideSoundView(toRight: true)
}
}
case .ended, .cancelled:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(
x: soundView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: soundView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestCornerPosition = nearestCorner(to: projectedPosition)
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: soundView.center.x, to: nearestCornerPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: soundView.center.y, to: nearestCornerPosition.y)
)
let timingParameters = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
animator.addAnimations {
self.soundView.center = nearestCornerPosition
}
animator.startAnimation()
}
default: break
}
}
private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}
private func nearestCorner(to point: CGPoint) -> CGPoint {
var minDistance = CGFloat.greatestFiniteMagnitude
var nearestPosition = CGPoint.zero
for position in [cornerPoints.topLeft, cornerPoints.bottomLeft, cornerPoints.bottomRight, cornerPoints.topRight] {
let distance = point.distance(to: position)
if distance < minDistance {
nearestPosition = position
minDistance = distance
}
}
return nearestPosition
}
/// Calculates the relative velocity needed for the initial velocity of the animation.
private func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {
guard currentValue - targetValue != 0 else { return 0 }
return velocity / (targetValue - currentValue)
}
}

Determining if custom iOS views overlap

I've defined a CircleView class:
class CircleView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
// Get the Graphics Context
if let context = UIGraphicsGetCurrentContext() {
// Set the circle outerline-width
context.setLineWidth(5.0);
// Set the circle outerline-colour
UIColor.blue.set()
// Create Circle
let center = CGPoint(x: frame.size.width/2, y: frame.size.height/2)
let radius = (frame.size.width - 10)/2
context.addArc(center: center, radius: radius, startAngle: 0.0, endAngle: .pi * 2.0, clockwise: true)
context.setFillColor(UIColor.blue.cgColor)
// Draw
context.strokePath()
context.fillPath()
}
}
}
And created an array of them with a randomly set number:
var numberOfCircles: Int!
var circles: [CircleView] = []
numberOfCircles = Int.random(in: 1..<10)
let circleWidth = CGFloat(50)
let circleHeight = circleWidth
var i = 0
while i < numberOfCircles {
let circleView = CircleView(frame: CGRect(x: 0.0, y: 0.0, width: circleWidth, height: circleHeight))
circles.append(circleView)
i += 1
}
After creating the circles, I call a function, drawCircles, that will draw them on the screen:
func drawCircles(){
for c in circles {
c.frame.origin = c.frame.randomPoint
while !UIScreen.main.bounds.contains(c.frame.origin) {
c.frame.origin = CGPoint()
c.frame.origin = c.frame.randomPoint
let prev = circles.before(c)
if prev?.frame.intersects(c.frame) == true {
c.frame.origin = c.frame.randomPoint
}
}
}
for c in circles {
self.view.addSubview(c)
}
}
The while loop in the drawCircles method makes sure that no circles are placed outside of the bounds of the screen, and works as expected.
What I'm struggling with is to make sure that the circles don't overlap each other, like so:
I'm using the following methods to determine either the next
I'm using this methods to determine what the previous / next element in the array of circles:
extension BidirectionalCollection where Iterator.Element: Equatable {
typealias Element = Self.Iterator.Element
func after(_ item: Element, loop: Bool = false) -> Element? {
if let itemIndex = self.firstIndex(of: item) {
let lastItem: Bool = (index(after:itemIndex) == endIndex)
if loop && lastItem {
return self.first
} else if lastItem {
return nil
} else {
return self[index(after:itemIndex)]
}
}
return nil
}
func before(_ item: Element, loop: Bool = false) -> Element? {
if let itemIndex = self.firstIndex(of: item) {
let firstItem: Bool = (itemIndex == startIndex)
if loop && firstItem {
return self.last
} else if firstItem {
return nil
} else {
return self[index(before:itemIndex)]
}
}
return nil
}
}
This if statement, however; doesn't seem to be doing what I'm wanting; which is to make sure that if a circle intersects with another one, to change it's origin to be something new:
if prev?.frame.intersects(c.frame) == true {
c.frame.origin = c.frame.randomPoint
}
If anyone has any ideas where the logic may be, or of other ideas on how to make sure that the circles don't overlap with each other, that would be helpful!
EDIT: I did try the suggestion that Eugene gave in his answer like so, but still get the same result:
func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
let xDist = a.x - b.x
let yDist = a.y - b.y
return CGFloat(sqrt(xDist * xDist + yDist * yDist))
}
if prev != nil {
if distance((prev?.frame.origin)!, c.frame.origin) <= 40 {
print("2")
c.frame.origin = CGPoint()
c.frame.origin = c.frame.randomPoint
}
}
But still the same result
EDIT 2
Modified my for loop based on Eugene's edited answer / clarifications; still having issues with overlapping circles:
for c in circles {
c.frame.origin = c.frame.randomPoint
let prev = circles.before(c)
let viewMidX = self.circlesView.bounds.midX
let viewMidY = self.circlesView.bounds.midY
let xPosition = self.circlesView.frame.midX - viewMidX + CGFloat(arc4random_uniform(UInt32(viewMidX*2)))
let yPosition = self.circlesView.frame.midY - viewMidY + CGFloat(arc4random_uniform(UInt32(viewMidY*2)))
if let prev = prev {
if distance(prev.center, c.center) <= 50 {
c.center = CGPoint(x: xPosition, y: yPosition)
}
}
}
That’s purely geometric challenge. Just ensure that distance between the circle centers greater than or equal to sum of their radiuses.
Edit 1
Use UIView.center instead of UIView.frame.origin. UIView.frame.origin gives you the top left corner of UIView.
if let prev = prev {
if distance(prev.center, c.center) <= 50 {
print("2")
c.center = ...
}
}
Edit 2
func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
let xDist = a.x - b.x
let yDist = a.y - b.y
return CGFloat(hypot(xDist, yDist))
}
let prev = circles.before(c)
if let prevCircleCenter = prev?.center {
let distance = distance(prevCenter, c.center)
if distance <= 50 {
let viewMidX = c.bounds.midX
let viewMidY = c.bounds.midY
var newCenter = c.center
var centersVector = CGVector(dx: newCenter.x - prevCircleCenter.x, dy: newCenter.y - prevCircleCenter.y)
centersVector.dx *= 51 / distance
centersVector.dy *= 51 / distance
newCenter.x = prevCircleCenter.x + centersVector.dx
newCenter.y = prevCircleCenter.y + centersVector.dy
c.center = newCenter
}
}

How to make waveform for my recorded audio?

I am using AVFoundation to record audio with the setting below.
After recording successfully, I need to show the waveform of the recorded file to the user. Can anyone help me with this task?
Here is my setting for recorder:
let recordSettings =
[AVNumberOfChannelsKey: 1,
AVFormatIDKey : kAudioFormatOpus,
AVSampleRateKey: 24000.0] as [String : Any]
import UIKit
import RYKit
let normalColor = UIColor.white
let normalAlphaColor = UIColor.init(white: 1.0, alpha: 0.5)
let highlightColor = UIColor.init(red: 163.0/255.0, green: 243.0/255.0, blue: 16.0/255.0, alpha: 1.0)
let highlightAlphaColor = UIColor.init(red: 163.0/255.0, green: 243.0/255.0, blue: 16.0/255.0, alpha: 0.24)
let waveWidth = CGFloat(2.5)
let waveSpace = CGFloat(0.5)
let waveRadius = CGFloat(1.25)
let upMaxHeight = CGFloat(60)
let downMaxHeight = CGFloat(30)
let upDownSpace = CGFloat(2)
protocol WaveformScrollDelegate: NSObjectProtocol {
func didScrollToTime(time: NSInteger)
func didScrollByPercentage(percent: Double, animated: Bool)
}
class WaveformComponent: UIView, CAAnimationDelegate, UIGestureRecognizerDelegate {
private var timeLine: UILabel!
private var topView: WaveformView!
private var topViewMask: CALayer!
private var bottomView: WaveformView!
private var isAnimated = false
private let convertTime = {
(seconds: Int) -> String in
let minute = seconds / 60
let minuteStr = minute > 9 ? "\(minute)" : "0\(minute)"
let second = seconds % 60
let secondStr = second > 9 ? "\(second)" : "0\(second)"
return "\(minuteStr):\(secondStr)"
}
var animationTimer: Timer!
weak var delegate: WaveformScrollDelegate?
var isVisible = true
/*
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
}
*/
required init?(coder: NSCoder) {
super.init(coder: coder)
}
init(frame: CGRect, amplitudes: [Double]) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
self.isOpaque = true
self.clipsToBounds = true
let width = (waveWidth + waveSpace) * CGFloat(amplitudes.count / 2)
let height = upMaxHeight + downMaxHeight + upDownSpace
let waveRect = CGRect.init(x: frame.size.width/2.0, y: (frame.size.height - height)/2.0, width: width, height: height)
bottomView = WaveformView.init(frame: waveRect, amplitudes: amplitudes, isHighlight: true)
self.addSubview(bottomView)
topView = WaveformView.init(frame: waveRect, amplitudes: amplitudes, isHighlight: false)
self.addSubview(topView)
topViewMask = CALayer()
topViewMask.frame = topView.bounds
topViewMask.backgroundColor = UIColor.white.cgColor
topView.layer.mask = topViewMask
timeLine = UILabel.init(frame: CGRect.init(x: (frame.size.width - 61.5)/2.0, y: (frame.size.height - upMaxHeight - upDownSpace - downMaxHeight)/2.0 + upMaxHeight - 19.0, width: 61.5, height: 19.0))
timeLine.backgroundColor = UIColor.init(red: 18/255.0, green: 18/255.0, blue: 18/255.0, alpha: 0.72)
timeLine.layer.cornerRadius = 9.5
timeLine.layer.masksToBounds = true
timeLine.textColor = UIColor.white
timeLine.font = UIFont.init(name: "PingFangSC-Regular", size: 8.0)
timeLine.textAlignment = .center
timeLine.text = "\(convertTime(0))/\(convertTime(amplitudes.count/2))"
self.addSubview(timeLine)
let panGesture = UIPanGestureRecognizer.init(target: self, action: #selector(handleGesture(gesture:)))
panGesture.delegate = self
addGestureRecognizer(panGesture)
isUserInteractionEnabled = true
}
func configureAmplitudes(amplitudes: [Double]) {
let width = (waveWidth + waveSpace) * CGFloat(amplitudes.count / 2)
let height = upMaxHeight + downMaxHeight + upDownSpace
self.topView.amplitudes = amplitudes
self.topView.frame = CGRect(x: screenw/2, y: 0, width: width, height: height)
self.topView.setNeedsDisplay()
topViewMask.frame = topView.bounds
self.bottomView.amplitudes = amplitudes
self.bottomView.frame = CGRect(x: screenw/2, y: 0, width: width, height: height)
self.bottomView.setNeedsDisplay()
}
func play() {
if !isAnimated {
isAnimated = true
topView.layer.add(keyframeAnimationFrom(topView.layer.position.x, to: (self.bounds.size.width - topView.layer.bounds.size.width)/2, isTop: false), forKey: "pan")
topViewMask.add(keyframeAnimationFrom(topViewMask.position.x, to: topViewMask.bounds.size.width*3/2, isTop: false), forKey: "pan")
bottomView.layer.add(keyframeAnimationFrom(bottomView.layer.position.x, to: (self.bounds.size.width - bottomView.layer.bounds.size.width)/2, isTop: false), forKey: "pan")
weak var weakSelf = self
animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { (timer) in
guard let presentation = weakSelf?.topView.layer.presentation() else { return }
let delta = (weakSelf!.bounds.size.width + weakSelf!.topView.bounds.size.width)/2 - presentation.position.x
weakSelf!.timeLine.text = "\(weakSelf!.convertTime(Int(round(delta / 3))))/\(weakSelf!.convertTime(weakSelf!.topView.amplitudes.count/2))"
if weakSelf!.delegate != nil {
let offset = delta / 3
let distance = weakSelf!.topView.amplitudes.count/2
if distance > 0 {
weakSelf!.delegate?.didScrollByPercentage(percent: Double(offset) / Double(distance), animated: true)
}else {
weakSelf!.delegate?.didScrollByPercentage(percent: 0, animated: true)
}
}
})
}
}
func pause() {
if isAnimated {
topView.layer.position = topView.layer.presentation()!.position
topViewMask.position = topViewMask.presentation()!.position
bottomView.layer.position = bottomView.layer.presentation()!.position
removeAnimate()
}
}
func reset() {
timeLine.text = "\(convertTime(0))/\(convertTime(topView.amplitudes.count/2))"
let position = CGPoint(x: (self.size.width + topView.size.width) / 2, y: self.size.height / 2)
topView.layer.position = position
topView.layer.removeAllAnimations()
topViewMask.position = CGPoint(x: topView.size.width / 2, y: topView.size.height / 2)
topViewMask.removeAllAnimations()
bottomView.layer.position = position
bottomView.layer.removeAllAnimations()
isAnimated = false
stopTimer()
}
func initialOffset(offset: Int) {
let position = CGPoint(x: (self.size.width + topView.size.width) / 2 - 3 * CGFloat(offset), y: self.size.height / 2)
topView.layer.position = position
topViewMask.position = CGPoint(x: topView.size.width / 2 + 3 * CGFloat(offset), y: topView.size.height / 2)
bottomView.layer.position = position
timeLine.text = "\(convertTime(offset))/\(convertTime(topView.amplitudes.count/2))"
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer.isKind(of: UISwipeGestureRecognizer.self) {
let swipe = otherGestureRecognizer as! UISwipeGestureRecognizer
if (swipe.direction == .up || swipe.direction == .down) && ((swipe.qmui_targetView?.parentViewController?.isKind(of: AudioPlayerViewController.self)) != nil) {
return true
}
}
return false
}
// func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// return true
// }
#objc private func handleGesture(gesture: UIPanGestureRecognizer) {
if gesture.state == .changed {
let translation = gesture.translation(in: self)
let absX = abs(translation.x)
let absY = abs(translation.y)
if (absX > absY ) {
if (translation.x < 0) {
//向左滑动
if isAnimated {
topView.layer.position = CGPoint.init(x: max(topView.layer.presentation()!.position.x + translation.x, (self.bounds.size.width - topView.layer.bounds.size.width)/2), y: topView.layer.position.y)
topViewMask.position = CGPoint.init(x: min(topViewMask.presentation()!.position.x - translation.x, topViewMask.bounds.size.width*3/2), y: topViewMask.position.y)
bottomView.layer.position = CGPoint.init(x: max(bottomView.layer.presentation()!.position.x + translation.x, (self.bounds.size.width - bottomView.layer.bounds.size.width)/2), y: bottomView.layer.position.y)
}else {
if topView.layer.frame.origin.x + topView.layer.frame.size.width <= self.bounds.size.width / 2 {
print("左滑,切歌下一曲")
return
}
topView.layer.position = CGPoint.init(x: max(topView.layer.position.x + translation.x, (self.bounds.size.width - topView.layer.bounds.size.width)/2), y: topView.layer.position.y)
topViewMask.position = CGPoint.init(x: min(topViewMask.position.x - translation.x, topViewMask.bounds.size.width*3/2), y: topViewMask.position.y)
bottomView.layer.position = CGPoint.init(x: max(bottomView.layer.position.x + translation.x, (self.bounds.size.width - bottomView.layer.bounds.size.width)/2), y: bottomView.layer.position.y)
}
gesture.setTranslation(CGPoint.zero, in: self)
}else{
//向右滑动
if isAnimated {
topView.layer.position = CGPoint.init(x: min(topView.layer.presentation()!.position.x + translation.x, (self.bounds.size.width + topView.layer.bounds.size.width)/2), y: topView.layer.position.y)
topViewMask.position = CGPoint.init(x: max(topViewMask.presentation()!.position.x - translation.x, topViewMask.bounds.size.width/2), y: topViewMask.position.y)
bottomView.layer.position = CGPoint.init(x: min(bottomView.layer.presentation()!.position.x + translation.x, (self.bounds.size.width + bottomView.layer.bounds.size.width)/2), y: bottomView.layer.position.y)
}else {
if topView.layer.frame.origin.x >= self.bounds.size.width / 2 {
print("右滑,切歌上一曲")
return
}
topView.layer.position = CGPoint.init(x: min(topView.layer.position.x + translation.x, (self.bounds.size.width + topView.layer.bounds.size.width)/2), y: topView.layer.position.y)
topViewMask.position = CGPoint.init(x: max(topViewMask.position.x - translation.x, topViewMask.bounds.size.width/2), y: topViewMask.position.y)
bottomView.layer.position = CGPoint.init(x: min(bottomView.layer.position.x + translation.x, (self.bounds.size.width + bottomView.layer.bounds.size.width)/2), y: bottomView.layer.position.y)
}
gesture.setTranslation(CGPoint.zero, in: self)
}
removeAnimate()
scrollTimeLineWhetherNotice(notice: false)
if delegate != nil {
let offset = (self.size.width + topView.size.width) / 2 - topView.layer.position.x
let distance = topView.size.width
delegate?.didScrollByPercentage(percent: Double(offset) / Double(distance), animated: false)
}
}
}
if gesture.state == .ended {
//考虑到歌曲存在缓冲,请手动调用play方法
// play()
scrollTimeLineWhetherNotice(notice: true)
}
}
private func scrollTimeLineWhetherNotice(notice: Bool) {
let delta = (self.bounds.size.width + self.topView.bounds.size.width)/2 - self.topView.layer.position.x
var time = NSInteger(round(delta / 3))
if time >= topView.amplitudes.count / 2 {
time = topView.amplitudes.count / 2 - 1
}
timeLine.text = "\(convertTime(time))/\(convertTime(topView.amplitudes.count/2))"
if delegate != nil && notice {
delegate?.didScrollToTime(time: time)
}
}
private func removeAnimate() {
if isAnimated {
isAnimated = false
topView.layer.removeAnimation(forKey: "pan")
topViewMask.removeAnimation(forKey: "pan")
bottomView.layer.removeAnimation(forKey: "pan")
}
}
private func keyframeAnimationFrom(_ start: CGFloat, to end: CGFloat, isTop:Bool) -> CAAnimation {
let animation = CAKeyframeAnimation.init(keyPath: "position.x")
let scale = UIScreen.main.scale
let increment = copysign(1, end - start) / scale
let numberOfSteps = Int(abs((end - start) / increment))
let positions = NSMutableArray.init(capacity: numberOfSteps)
for i in 0..<numberOfSteps {
positions.add(start + CGFloat(i) * increment)
}
animation.values = (positions as! [Any])
animation.calculationMode = .discrete
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
animation.duration = Double(Int(abs(end-start) / (AppConstants.waveWidth + AppConstants.waveSpace)))
animation.delegate = self
return animation
}
func animationDidStart(_ anim: CAAnimation) {
if anim == topView.layer.animation(forKey: "pan") {
}
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if UIApplication.shared.applicationState == .active && isVisible {
if isAnimated {
reset()
}
stopTimer()
}
}
private func stopTimer() {
guard let animationTimer = self.animationTimer else {
return
}
if animationTimer.isValid {
self.animationTimer.invalidate()
self.animationTimer = nil
}
}
deinit {
print("release WaveformComponent")
}
}
class WaveformView: UIView {
var isHighlight = false
var amplitudes = [Double]()
required init?(coder: NSCoder) {
super.init(coder: coder)
}
init(frame: CGRect, amplitudes: [Double], isHighlight: Bool) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
self.isOpaque = true
self.amplitudes = amplitudes
self.isHighlight = isHighlight
}
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
guard let context = UIGraphicsGetCurrentContext() else { return }
for i in 0..<amplitudes.count {
if i%2 == 0 {
//单数
let path = CGMutablePath()
let height = downMaxHeight * CGFloat(abs(amplitudes[i]))
path.addRoundedRect(in: CGRect.init(x: CGFloat(Int(i/2)) * (waveWidth + waveSpace), y: 62, width: 2.5, height: height), cornerWidth: 1.25, cornerHeight: 1.25 >= height/2.0 ? 0 : 1.25)
context.addPath(path)
if isHighlight {
context.setFillColor(highlightAlphaColor.cgColor)
}else {
context.setFillColor(normalAlphaColor.cgColor)
}
context.fillPath()
}else {
//双数
let path = CGMutablePath()
let height = upMaxHeight * CGFloat(abs(amplitudes[i]))
path.addRoundedRect(in: CGRect.init(x: CGFloat(Int(i/2)) * (waveWidth + waveSpace), y: 60 - height, width: 2.5, height: height), cornerWidth: 1.25, cornerHeight: 1.25 >= height/2.0 ? 0 : 1.25)
context.addPath(path)
if isHighlight {
context.setFillColor(highlightColor.cgColor)
}else {
context.setFillColor(normalColor.cgColor)
}
context.fillPath()
}
}
}
}
I don’t know if you want to create waveform from the scratch or custom, but there is a library called FDWaveformView and I have ever use this library in the past. After you install this library to your project, you can add a UIView which inherit FDWaveformView class, then provide the audio file.
Your code will likely look like this
import UIKit
import FDWaveformView
class ViewController: UIViewController {
#IBOutlet weak var mySampleWaveform: FDWaveformView!
override func viewDidLoad() {
super.viewDidLoad()
let thisBundle = Bundle(for: type(of: self))
let url = thisBundle.url(forResource: "myaudio", withExtension: "mp3")
mySampleWaveform.audioURL = url
mySampleWaveform.wavesColor = .green
mySampleWaveform.doesAllowScrubbing = true
mySampleWaveform.doesAllowStretch = true
mySampleWaveform.doesAllowScroll = true
}
}
it will show like this:
This will give you enough understanding about how does a waveform work, and you can custom many things such as color, width, height, etc.

How to make Circular audio visualizer in swift?

I want to make a visualizer like this Circular visualizer, click the green flag to see the animation.
In my project first I draw a circle, I calculate the points on the circle to draw the visualizer bars, I rotate the view to make the bars feels like circle. I use StreamingKit to stream live radio. StreamingKit provides the live audio power in decibels. Then I animate the visualizer bars. But when I rotate the view the height and width changes according to the angle I rotate. But the bounds value not change (I know the frame depends on superViews).
audioSpectrom Class
class audioSpectrom: UIView {
let animateDuration = 0.15
let visualizerColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
var barsNumber = 0
let barWidth = 4 // width of bar
let radius: CGFloat = 40
var radians = [CGFloat]()
var barPoints = [CGPoint]()
private var rectArray = [CustomView]()
private var waveFormArray = [Int]()
private var initialBarHeight: CGFloat = 0.0
private let mainLayer: CALayer = CALayer()
// draw circle
var midViewX: CGFloat!
var midViewY: CGFloat!
var circlePath = UIBezierPath()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
convenience init() {
self.init(frame: CGRect.zero)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
private func setupView() {
self.layer.addSublayer(mainLayer)
barsNumber = 10
}
override func layoutSubviews() {
mainLayer.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
drawVisualizer()
}
//-----------------------------------------------------------------
// MARK: - Drawing Section
//-----------------------------------------------------------------
func drawVisualizer() {
midViewX = self.mainLayer.frame.midX
midViewY = self.mainLayer.frame.midY
// Draw Circle
let arcCenter = CGPoint(x: midViewX, y: midViewY)
let circlePath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true)
let circleShapeLayer = CAShapeLayer()
circleShapeLayer.path = circlePath.cgPath
circleShapeLayer.fillColor = UIColor.blue.cgColor
circleShapeLayer.strokeColor = UIColor.clear.cgColor
circleShapeLayer.lineWidth = 1.0
mainLayer.addSublayer(circleShapeLayer)
// Draw Bars
rectArray = [CustomView]()
for i in 0..<barsNumber {
let angle = ((360 / barsNumber) * i) - 90
let point = calculatePoints(angle: angle, radius: radius)
let radian = angle.degreesToRadians
radians.append(radian)
barPoints.append(point)
let rectangle = CustomView(frame: CGRect(x: barPoints[i].x, y: barPoints[i].y, width: CGFloat(barWidth), height: CGFloat(barWidth)))
initialBarHeight = CGFloat(self.barWidth)
rectangle.setAnchorPoint(anchorPoint: CGPoint.zero)
let rotationAngle = (CGFloat(( 360/barsNumber) * i)).degreesToRadians + 180.degreesToRadians
rectangle.transform = CGAffineTransform(rotationAngle: rotationAngle)
rectangle.backgroundColor = visualizerColor
rectangle.layer.cornerRadius = CGFloat(rectangle.bounds.width / 2)
rectangle.tag = i
self.addSubview(rectangle)
rectArray.append(rectangle)
var values = [5, 10, 15, 10, 5, 1]
waveFormArray = [Int]()
var j: Int = 0
for _ in 0..<barsNumber {
waveFormArray.append(values[j])
j += 1
if j == values.count {
j = 0
}
}
}
}
//-----------------------------------------------------------------
// MARK: - Animation Section
//-----------------------------------------------------------------
func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
DispatchQueue.main.async {
UIView.animateKeyframes(withDuration: self.animateDuration, delay: 0, options: .beginFromCurrentState, animations: {
for i in 0..<self.barsNumber {
let channelValue: Int = Int(arc4random_uniform(2))
let wavePeak: Int = Int(arc4random_uniform(UInt32(self.waveFormArray[i])))
let barView = self.rectArray[i] as? CustomView
guard var barFrame = barView?.frame else { return }
// calculate the bar height
let barH = (self.frame.height / 2 ) - self.radius
// scale the value to 40, input value of this func range from 0-60, 60 is low and 0 is high. Then calculate the height by minimise the scaled height from bar height.
let scaled0 = (CGFloat(level0) * barH) / 60
let scaled1 = (CGFloat(level1) * barH) / 60
let calc0 = barH - scaled0
let calc1 = barH - scaled1
if channelValue == 0 {
barFrame.size.height = calc0
} else {
barFrame.size.height = calc1
}
if barFrame.size.height < 4 || barFrame.size.height > ((self.frame.size.height / 2) - self.radius) {
barFrame.size.height = self.initialBarHeight + CGFloat(wavePeak)
}
barView?.frame = barFrame
}
}, completion: nil)
}
}
func calculatePoints(angle: Int, radius: CGFloat) -> CGPoint {
let barX = midViewX + cos((angle).degreesToRadians) * radius
let barY = midViewY + sin((angle).degreesToRadians) * radius
return CGPoint(x: barX, y: barY)
}
}
extension BinaryInteger {
var degreesToRadians: CGFloat { return CGFloat(Int(self)) * .pi / 180 }
}
extension FloatingPoint {
var degreesToRadians: Self { return self * .pi / 180 }
var radiansToDegrees: Self { return self * 180 / .pi }
}
extension UIView{
func setAnchorPoint(anchorPoint: CGPoint) {
var newPoint = CGPoint(x: self.bounds.size.width * anchorPoint.x, y: self.bounds.size.height * anchorPoint.y)
var oldPoint = CGPoint(x: self.bounds.size.width * self.layer.anchorPoint.x, y: self.bounds.size.height * self.layer.anchorPoint.y)
newPoint = newPoint.applying(self.transform)
oldPoint = oldPoint.applying(self.transform)
var position : CGPoint = self.layer.position
position.x -= oldPoint.x
position.x += newPoint.x;
position.y -= oldPoint.y;
position.y += newPoint.y;
self.layer.position = position;
self.layer.anchorPoint = anchorPoint;
}
}
I drag a empty view to storyBoard and give custom class as audioSpectrom.
ViewController
func startAudioVisualizer() {
visualizerTimer?.invalidate()
visualizerTimer = nil
visualizerTimer = Timer.scheduledTimer(timeInterval: visualizerAnimationDuration, target: self, selector: #selector(self.visualizerTimerFunc), userInfo: nil, repeats: true)
}
#objc func visualizerTimerFunc(_ timer: CADisplayLink) {
let lowResults = self.audioPlayer!.averagePowerInDecibels(forChannel: 0)
let lowResults1 = self.audioPlayer!.averagePowerInDecibels(forChannel: 1)
audioSpectrom.animateAudioVisualizerWithChannel(level0: -lowResults, level1: -lowResults1)
}
OUTPUT
Without animation
With animation
In my observation, the height value and width value of frame changed when rotates. Means when I give CGSize(width: 4, height: 4) to bar, then when I rotate using some angle it changes the size of frame like CGSize(width: 3.563456, height: 5.67849) (not sure for the value, it's an assumption).
How to resolve this problem?
Any suggestions or answers will be appreciated.
Edit
func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
DispatchQueue.main.async {
UIView.animateKeyframes(withDuration: self.animateDuration, delay: 0, options: .beginFromCurrentState, animations: {
for i in 0..<self.barsNumber {
let channelValue: Int = Int(arc4random_uniform(2))
let wavePeak: Int = Int(arc4random_uniform(UInt32(self.waveFormArray[i])))
var barView = self.rectArray[i] as? CustomView
guard let barViewUn = barView else { return }
let barH = (self.frame.height / 2 ) - self.radius
let scaled0 = (CGFloat(level0) * barH) / 60
let scaled1 = (CGFloat(level1) * barH) / 60
let calc0 = barH - scaled0
let calc1 = barH - scaled1
let kSavedTransform = barViewUn.transform
barViewUn.transform = .identity
if channelValue == 0 {
barViewUn.frame.size.height = calc0
} else {
barViewUn.frame.size.height = calc1
}
if barViewUn.frame.height < CGFloat(4) || barViewUn.frame.height > ((self.frame.size.height / 2) - self.radius) {
barViewUn.frame.size.height = self.initialBarHeight + CGFloat(wavePeak)
}
barViewUn.transform = kSavedTransform
barView = barViewUn
}
}, completion: nil)
}
}
Output
Run the below code snippet show the output
<img src="https://i.imgflip.com/227xsa.gif" title="made at imgflip.com"/>
GOT IT!!
circular-visualizer
There are two (maybe three) issues in your code:
1. audioSpectrom.layoutSubviews()
You create new views in layoutSubviews and add them to the view hierarchy. This is not what you are intened to do, because layoutSubviews is called multiple times and you should use it only for layouting purposes.
As a dirty work-around, I modified the code in the func drawVisualizer to only add the bars once:
func drawVisualizer() {
// ... some code here
// ...
mainLayer.addSublayer(circleShapeLayer)
// This will ensure to only add the bars once:
guard rectArray.count == 0 else { return } // If we already have bars, just return
// Draw Bars
rectArray = [CustomView]()
// ... Rest of the func
}
Now, it almost looks good, but there are still some dirt effects with the topmost bar. So you'll have to change
2. audioSectrom.animateAudioVisualizerWithChannel(level0:level1:)
Here, you want to recalculate the frame of the bars. Since they are rotated, the frame also is rotated, and you'd have to apply some mathematical tricks. To avoid this adn make your life more easy, you save the rotated transform, set it to .identity, modify the frame, and then restore the original rotated transform. Unfortunately, this causes some dirt effects with rotations of 0 or 2pi, maybe caused by some rounding issues. Never mind, there is a much more simple solution:
Instead of modifiying the frame, you better modify the bounds.
frame is measured in the outer (in your case: rotated) coordinate system
bounds is measured in the inner (non-transformed) coordinate system
So I simply replaced all the frames with bounds in the function animateAudioVisualizerWithChannel and also removed the saving and restoring of the transformation matrix:
func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
// some code before
guard let barViewUn = barView else { return }
let barH = (self.bounds.height / 2 ) - self.radius
let scaled0 = (CGFloat(level0) * barH) / 60
let scaled1 = (CGFloat(level1) * barH) / 60
let calc0 = barH - scaled0
let calc1 = barH - scaled1
if channelValue == 0 {
barViewUn.bounds.size.height = calc0
} else {
barViewUn.bounds.size.height = calc1
}
if barViewUn.bounds.height < CGFloat(4) || barViewUn.bounds.height > ((self.bounds.height / 2) - self.radius) {
barViewUn.bounds.size.height = self.initialBarHeight + CGFloat(wavePeak)
}
barView = barViewUn
// some code after
}
3. Warnings
By the way, you should get rid of all the warnings in your code. I didn't clean up my answer code to keep it comparable with the orginal code.
For example, in var barView = self.rectArray[i] as? CustomView you don't need the conditional cast, because the array already contains CustomView objects.
So, all the barViewUn stuff is unnecessary.
Much more to find and to clean up.

How to implement range slider in Swift

I'm trying to implement Range Slider and I used custom control called NMRangeSlider.
But when I use it, the slider doesn't appear at all. Could it be also because it's all written in Objective-C?
This is how I've currently implemented it:
var rangeSlider = NMRangeSlider(frame: CGRectMake(16, 6, 275, 34))
rangeSlider.lowerValue = 0.54
rangeSlider.upperValue = 0.94
self.view.addSubview(rangeSlider)
To create a custom Range Slider I found a good solution here: range finder tutorial iOS 8 but I needed this in swift 3 for my project. I updated this for Swift 3 iOS 10 here:
in your main view controller add this to viewDidLayOut to show a range slider.
override func viewDidLayoutSubviews() {
let margin: CGFloat = 20.0
let width = view.bounds.width - 2.0 * margin
rangeSlider.frame = CGRect(x: margin, y: margin + topLayoutGuide.length + 170, width: width, height: 31.0)
}
create the helper function to print slider output below viewDidLayoutSubviews()
func rangeSliderValueChanged() { //rangeSlider: RangeSlider
print("Range slider value changed: \(rangeSlider.lowerValue) \(rangeSlider.upperValue) ")//(\(rangeSlider.lowerValue) \(rangeSlider.upperValue))
}
Create the file RangeSlider.swift and add this to it:
import UIKit
import QuartzCore
class RangeSlider: UIControl {
var minimumValue = 0.0
var maximumValue = 1.0
var lowerValue = 0.2
var upperValue = 0.8
let trackLayer = RangeSliderTrackLayer()//= CALayer() defined in RangeSliderTrackLayer.swift
let lowerThumbLayer = RangeSliderThumbLayer()//CALayer()
let upperThumbLayer = RangeSliderThumbLayer()//CALayer()
var previousLocation = CGPoint()
var trackTintColor = UIColor(white: 0.9, alpha: 1.0)
var trackHighlightTintColor = UIColor(red: 0.0, green: 0.45, blue: 0.94, alpha: 1.0)
var thumbTintColor = UIColor.white
var curvaceousness : CGFloat = 1.0
var thumbWidth: CGFloat {
return CGFloat(bounds.height)
}
override init(frame: CGRect) {
super.init(frame: frame)
trackLayer.rangeSlider = self
trackLayer.contentsScale = UIScreen.main.scale
layer.addSublayer(trackLayer)
lowerThumbLayer.rangeSlider = self
lowerThumbLayer.contentsScale = UIScreen.main.scale
layer.addSublayer(lowerThumbLayer)
upperThumbLayer.rangeSlider = self
upperThumbLayer.contentsScale = UIScreen.main.scale
layer.addSublayer(upperThumbLayer)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
func updateLayerFrames() {
trackLayer.frame = bounds.insetBy(dx: 0.0, dy: bounds.height / 3)
trackLayer.setNeedsDisplay()
let lowerThumbCenter = CGFloat(positionForValue(value: lowerValue))
lowerThumbLayer.frame = CGRect(x: lowerThumbCenter - thumbWidth / 2.0, y: 0.0,
width: thumbWidth, height: thumbWidth)
lowerThumbLayer.setNeedsDisplay()
let upperThumbCenter = CGFloat(positionForValue(value: upperValue))
upperThumbLayer.frame = CGRect(x: upperThumbCenter - thumbWidth / 2.0, y: 0.0,
width: thumbWidth, height: thumbWidth)
upperThumbLayer.setNeedsDisplay()
}
func positionForValue(value: Double) -> Double {
return Double(bounds.width - thumbWidth) * (value - minimumValue) /
(maximumValue - minimumValue) + Double(thumbWidth / 2.0)
}
override var frame: CGRect {
didSet {
updateLayerFrames()
}
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
previousLocation = touch.location(in: self)
// Hit test the thumb layers
if lowerThumbLayer.frame.contains(previousLocation) {
lowerThumbLayer.highlighted = true
} else if upperThumbLayer.frame.contains(previousLocation) {
upperThumbLayer.highlighted = true
}
return lowerThumbLayer.highlighted || upperThumbLayer.highlighted
}
func boundValue(value: Double, toLowerValue lowerValue: Double, upperValue: Double) -> Double {
return min(max(value, lowerValue), upperValue)
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self)
// 1. Determine by how much the user has dragged
let deltaLocation = Double(location.x - previousLocation.x)
let deltaValue = (maximumValue - minimumValue) * deltaLocation / Double(bounds.width - thumbWidth)
previousLocation = location
// 2. Update the values
if lowerThumbLayer.highlighted {
lowerValue += deltaValue
lowerValue = boundValue(value: lowerValue, toLowerValue: minimumValue, upperValue: upperValue)
} else if upperThumbLayer.highlighted {
upperValue += deltaValue
upperValue = boundValue(value: upperValue, toLowerValue: lowerValue, upperValue: maximumValue)
}
// 3. Update the UI
CATransaction.begin()
CATransaction.setDisableActions(true)
updateLayerFrames()
CATransaction.commit()
sendActions(for: .valueChanged)
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
lowerThumbLayer.highlighted = false
upperThumbLayer.highlighted = false
}
}
Next add the thumb layer subclass file RangeSliderThumbLayer.swift and add this to it:
import UIKit
class RangeSliderThumbLayer: CALayer {
var highlighted = false
weak var rangeSlider: RangeSlider?
override func draw(in ctx: CGContext) {
if let slider = rangeSlider {
let thumbFrame = bounds.insetBy(dx: 2.0, dy: 2.0)
let cornerRadius = thumbFrame.height * slider.curvaceousness / 2.0
let thumbPath = UIBezierPath(roundedRect: thumbFrame, cornerRadius: cornerRadius)
// Fill - with a subtle shadow
let shadowColor = UIColor.gray
ctx.setShadow(offset: CGSize(width: 0.0, height: 1.0), blur: 1.0, color: shadowColor.cgColor)
ctx.setFillColor(slider.thumbTintColor.cgColor)
ctx.addPath(thumbPath.cgPath)
ctx.fillPath()
// Outline
ctx.setStrokeColor(shadowColor.cgColor)
ctx.setLineWidth(0.5)
ctx.addPath(thumbPath.cgPath)
ctx.strokePath()
if highlighted {
ctx.setFillColor(UIColor(white: 0.0, alpha: 0.1).cgColor)
ctx.addPath(thumbPath.cgPath)
ctx.fillPath()
}
}
}
}
Finally add the track layer subclass file RangeSliderTrackLayer.swift and add the following to it:
import Foundation
import UIKit
import QuartzCore
class RangeSliderTrackLayer: CALayer {
weak var rangeSlider: RangeSlider?
override func draw(in ctx: CGContext) {
if let slider = rangeSlider {
// Clip
let cornerRadius = bounds.height * slider.curvaceousness / 2.0
let path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
ctx.addPath(path.cgPath)
// Fill the track
ctx.setFillColor(slider.trackTintColor.cgColor)
ctx.addPath(path.cgPath)
ctx.fillPath()
// Fill the highlighted range
ctx.setFillColor(slider.trackHighlightTintColor.cgColor)
let lowerValuePosition = CGFloat(slider.positionForValue(value: slider.lowerValue))
let upperValuePosition = CGFloat(slider.positionForValue(value: slider.upperValue))
let rect = CGRect(x: lowerValuePosition, y: 0.0, width: upperValuePosition - lowerValuePosition, height: bounds.height)
ctx.fill(rect)
}
}
}
Build Run and Get:
UPDATE:
It did not show to me, because it was all white. So the solution, without using any other framework and sticking with this one - you need to set all the views for all the components and then it will display well:
I have tried to import it in Swift as I used it before in Objective-C code, but without any luck. If I set everything properly and add it either in viewDidLoad() or viewDidAppear(), nothing gets displayed. One thing is worth mentioning, though - when I enter View Debug Hierarchy, the slider actually is there on the canvas:
But it's simply not rendered with all the colors that I did set before adding in it to the view. For the record - this is the code I used:
override func viewDidAppear(animated: Bool) {
var rangeSlider = NMRangeSlider(frame: CGRectMake(50, 50, 275, 34))
rangeSlider.lowerValue = 0.54
rangeSlider.upperValue = 0.94
let range = 10.0
let oneStep = 1.0 / range
let minRange: Float = 0.05
rangeSlider.minimumRange = minRange
let bgImage = UIView(frame: rangeSlider.frame)
bgImage.backgroundColor = .greenColor()
rangeSlider.trackImage = bgImage.pb_takeSnapshot()
let trackView = UIView(frame: CGRectMake(0, 0, rangeSlider.frame.size.width, 29))
trackView.backgroundColor = .whiteColor()
trackView.opaque = false
trackView.alpha = 0.3
rangeSlider.trackImage = UIImage(named: "")
let lowerThumb = UIView(frame: CGRectMake(0, 0, 8, 29))
lowerThumb.backgroundColor = .whiteColor()
let lowerThumbHigh = UIView(frame: CGRectMake(0, 0, 8, 29))
lowerThumbHigh.backgroundColor = UIColor.blueColor()
rangeSlider.lowerHandleImageNormal = lowerThumb.pb_takeSnapshot()
rangeSlider.lowerHandleImageHighlighted = lowerThumbHigh.pb_takeSnapshot()
rangeSlider.upperHandleImageNormal = lowerThumb.pb_takeSnapshot()
rangeSlider.upperHandleImageHighlighted = lowerThumbHigh.pb_takeSnapshot()
self.view.addSubview(rangeSlider)
self.view.backgroundColor = .lightGrayColor()
}
Using the method for capturing the UIView as UIImage mentioned in this question:
extension UIView {
func pb_takeSnapshot() -> UIImage {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, UIScreen.mainScreen().scale)
drawViewHierarchyInRect(self.bounds, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
}
Other solution:
You can also try sgwilly/RangeSlider instead, it's written in Swift and therefore you won't even need a Bridging Header.
try this code :
override func viewDidLayoutSubviews() {
let margin: CGFloat = 20.0
let width = view.bounds.width - 2.0 * margin
rangeSlider.frame = CGRect(x: margin, y: margin + topLayoutGuide.length,
width: width, height: 31.0)
}
I implemented the range slider using :
https://github.com/Zengzhihui/RangeSlider
In the GZRangeSlider class, there is a method called :
private func setLabelText()
In that method, just put :
leftTextLayer.frame = CGRectMake(leftHandleLayer.frame.minX - 0.5 * (kTextWidth - leftHandleLayer.frame.width), leftHandleLayer.frame.minY - kTextHeight, kTextWidth, kTextHeight)
rightTextLayer.frame = CGRectMake(rightHandleLayer.frame.minX - 0.5 * (kTextWidth - leftHandleLayer.frame.width), leftTextLayer.frame.minY, kTextWidth, kTextHeight)
to animate the lower and upper labels..
This one is working well for me and its in swift.. just try it..

Resources