iOS voice recorder visualization on swift - ios

I want to make visualization on the record like on the original Voice Memo app:
I know I can get the levels
- updateMeters
- peakPowerForChannel:
- averagePowerForChannel:
but how to draw the graphic, should I do it custom? Is there free/paid source I can use?

I was having the same problem. I wanted to create a voice memos clone. Recently, I found a solution and wrote an article about it on medium.
I created a subclass from UIView class and drew the bars with CGRect.
import UIKit
class AudioVisualizerView: UIView {
// Bar width
var barWidth: CGFloat = 4.0
// Indicate that waveform should draw active/inactive state
var active = false {
didSet {
if self.active {
self.color = UIColor.red.cgColor
}
else {
self.color = UIColor.gray.cgColor
}
}
}
// Color for bars
var color = UIColor.gray.cgColor
// Given waveforms
var waveforms: [Int] = Array(repeating: 0, count: 100)
// MARK: - Init
override init (frame : CGRect) {
super.init(frame : frame)
self.backgroundColor = UIColor.clear
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
self.backgroundColor = UIColor.clear
}
// MARK: - Draw bars
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.clear(rect)
context.setFillColor(red: 0, green: 0, blue: 0, alpha: 0)
context.fill(rect)
context.setLineWidth(1)
context.setStrokeColor(self.color)
let w = rect.size.width
let h = rect.size.height
let t = Int(w / self.barWidth)
let s = max(0, self.waveforms.count - t)
let m = h / 2
let r = self.barWidth / 2
let x = m - r
var bar: CGFloat = 0
for i in s ..< self.waveforms.count {
var v = h * CGFloat(self.waveforms[i]) / 50.0
if v > x {
v = x
}
else if v < 3 {
v = 3
}
let oneX = bar * self.barWidth
var oneY: CGFloat = 0
let twoX = oneX + r
var twoY: CGFloat = 0
var twoS: CGFloat = 0
var twoE: CGFloat = 0
var twoC: Bool = false
let threeX = twoX + r
let threeY = m
if i % 2 == 1 {
oneY = m - v
twoY = m - v
twoS = -180.degreesToRadians
twoE = 0.degreesToRadians
twoC = false
}
else {
oneY = m + v
twoY = m + v
twoS = 180.degreesToRadians
twoE = 0.degreesToRadians
twoC = true
}
context.move(to: CGPoint(x: oneX, y: m))
context.addLine(to: CGPoint(x: oneX, y: oneY))
context.addArc(center: CGPoint(x: twoX, y: twoY), radius: r, startAngle: twoS, endAngle: twoE, clockwise: twoC)
context.addLine(to: CGPoint(x: threeX, y: threeY))
context.strokePath()
bar += 1
}
}
}
For the recording function, I used installTap instance method to record, monitor, and observe the output of the node.
let inputNode = self.audioEngine.inputNode
guard let format = self.format() else {
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { (buffer, time) in
let level: Float = -50
let length: UInt32 = 1024
buffer.frameLength = length
let channels = UnsafeBufferPointer(start: buffer.floatChannelData, count: Int(buffer.format.channelCount))
var value: Float = 0
vDSP_meamgv(channels[0], 1, &value, vDSP_Length(length))
var average: Float = ((value == 0) ? -100 : 20.0 * log10f(value))
if average > 0 {
average = 0
} else if average < -100 {
average = -100
}
let silent = average < level
let ts = NSDate().timeIntervalSince1970
if ts - self.renderTs > 0.1 {
let floats = UnsafeBufferPointer(start: channels[0], count: Int(buffer.frameLength))
let frame = floats.map({ (f) -> Int in
return Int(f * Float(Int16.max))
})
DispatchQueue.main.async {
let seconds = (ts - self.recordingTs)
self.timeLabel.text = seconds.toTimeString
self.renderTs = ts
let len = self.audioView.waveforms.count
for i in 0 ..< len {
let idx = ((frame.count - 1) * i) / len
let f: Float = sqrt(1.5 * abs(Float(frame[idx])) / Float(Int16.max))
self.audioView.waveforms[i] = min(49, Int(f * 50))
}
self.audioView.active = !silent
self.audioView.setNeedsDisplay()
}
}
Here is the article I wrote, and I hope that you will find what you are looking for:
https://medium.com/flawless-app-stories/how-i-created-apples-voice-memos-clone-b6cd6d65f580
The project is also available on GitHub:
https://github.com/HassanElDesouky/VoiceMemosClone
Please note that I'm still a beginner, and I'm sorry my code doesn't seem that clean!

Related

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 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.

Text background with round corner like Instagram does

I want to create text with background color and round corners like Instagram does. I am able to achieve the background color but could not create the round corners.
What I have till now:
Below is the source code of above screenshot:
-(void)createBackgroundColor{
[self.txtView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, self.txtView.text.length) usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop) {
[textArray addObject:[NSNumber numberWithInteger:glyphRange.length]];
if (glyphRange.length == 1){
return ;
}
UIImageView *highlightBackView = [[UIImageView alloc] initWithFrame:CGRectMake(usedRect.origin.x, usedRect.origin.y , usedRect.size.width, usedRect.size.height + 2)];
highlightBackView.layer.borderWidth = 1;
highlightBackView.backgroundColor = [UIColor orangeColor];
highlightBackView.layer.borderColor = [[UIColor clearColor] CGColor];
[self.txtView insertSubview:highlightBackView atIndex:0];
highlightBackView.layer.cornerRadius = 5;
}];
}
I call this function in shouldChangeTextInRange delegate.
What I want:
See the inner radius marked with arrows, Any help would be appreciated!
UPDATE
I have rewritten my implementation of this code and made it available as a SwiftPM package: the RectangleContour package. The package includes an explanation of how to use its API and demo apps for macOS and iOS.
ORIGINAL
So, you want this:
Here's an answer that I spent way too long on, and that you probably won't even like, because your question is tagged objective-c but I wrote this answer in Swift. You can use Swift code from Objective-C, but not everyone wants to.
You can find my entire test project, including iOS and macOS test apps, in this github repo.
Anyway, what we need to do is compute the contour of the union of all of the line rects. I found a 1980 paper describing the necessary algorithm:
Lipski, W. and F. Preparata. “Finding the Contour of a Union of Iso-Oriented Rectangles.” J. Algorithms 1 (1980): 235-246. doi:10.1016/0196-6774(80)90011-5
This algorithm is probably more general than actually required for your problem, since it can handle rectangle arrangements that create holes:
So it might be overkill for you, but it gets the job done.
Anyway, once we have the contour, we can convert it to a CGPath with rounded corners for stroking or filling.
The algorithm is somewhat involved, but I implemented it (in Swift) as an extension method on CGPath:
import CoreGraphics
extension CGPath {
static func makeUnion(of rects: [CGRect], cornerRadius: CGFloat) -> CGPath {
let phase2 = AlgorithmPhase2(cornerRadius: cornerRadius)
_ = AlgorithmPhase1(rects: rects, phase2: phase2)
return phase2.makePath()
}
}
fileprivate func swapped<A, B>(_ pair: (A, B)) -> (B, A) { return (pair.1, pair.0) }
fileprivate class AlgorithmPhase1 {
init(rects: [CGRect], phase2: AlgorithmPhase2) {
self.phase2 = phase2
xs = Array(Set(rects.map({ $0.origin.x})).union(rects.map({ $0.origin.x + $0.size.width }))).sorted()
indexOfX = [CGFloat:Int](uniqueKeysWithValues: xs.enumerated().map(swapped))
ys = Array(Set(rects.map({ $0.origin.y})).union(rects.map({ $0.origin.y + $0.size.height }))).sorted()
indexOfY = [CGFloat:Int](uniqueKeysWithValues: ys.enumerated().map(swapped))
segments.reserveCapacity(2 * ys.count)
_ = makeSegment(y0: 0, y1: ys.count - 1)
let sides = (rects.map({ makeSide(direction: .down, rect: $0) }) + rects.map({ makeSide(direction: .up, rect: $0)})).sorted()
var priorX = 0
var priorDirection = VerticalDirection.down
for side in sides {
if side.x != priorX || side.direction != priorDirection {
convertStackToPhase2Sides(atX: priorX, direction: priorDirection)
priorX = side.x
priorDirection = side.direction
}
switch priorDirection {
case .down:
pushEmptySegmentsOfSegmentTree(atIndex: 0, thatOverlap: side)
adjustInsertionCountsOfSegmentTree(atIndex: 0, by: 1, for: side)
case .up:
adjustInsertionCountsOfSegmentTree(atIndex: 0, by: -1, for: side)
pushEmptySegmentsOfSegmentTree(atIndex: 0, thatOverlap: side)
}
}
convertStackToPhase2Sides(atX: priorX, direction: priorDirection)
}
private let phase2: AlgorithmPhase2
private let xs: [CGFloat]
private let indexOfX: [CGFloat: Int]
private let ys: [CGFloat]
private let indexOfY: [CGFloat: Int]
private var segments: [Segment] = []
private var stack: [(Int, Int)] = []
private struct Segment {
var y0: Int
var y1: Int
var insertions = 0
var status = Status.empty
var leftChildIndex: Int?
var rightChildIndex: Int?
var mid: Int { return (y0 + y1 + 1) / 2 }
func withChildrenThatOverlap(_ side: Side, do body: (_ childIndex: Int) -> ()) {
if side.y0 < mid, let l = leftChildIndex { body(l) }
if mid < side.y1, let r = rightChildIndex { body(r) }
}
init(y0: Int, y1: Int) {
self.y0 = y0
self.y1 = y1
}
enum Status {
case empty
case partial
case full
}
}
private struct /*Vertical*/Side: Comparable {
var x: Int
var direction: VerticalDirection
var y0: Int
var y1: Int
func fullyContains(_ segment: Segment) -> Bool {
return y0 <= segment.y0 && segment.y1 <= y1
}
static func ==(lhs: Side, rhs: Side) -> Bool {
return lhs.x == rhs.x && lhs.direction == rhs.direction && lhs.y0 == rhs.y0 && lhs.y1 == rhs.y1
}
static func <(lhs: Side, rhs: Side) -> Bool {
if lhs.x < rhs.x { return true }
if lhs.x > rhs.x { return false }
if lhs.direction.rawValue < rhs.direction.rawValue { return true }
if lhs.direction.rawValue > rhs.direction.rawValue { return false }
if lhs.y0 < rhs.y0 { return true }
if lhs.y0 > rhs.y0 { return false }
return lhs.y1 < rhs.y1
}
}
private func makeSegment(y0: Int, y1: Int) -> Int {
let index = segments.count
let segment: Segment = Segment(y0: y0, y1: y1)
segments.append(segment)
if y1 - y0 > 1 {
let mid = segment.mid
segments[index].leftChildIndex = makeSegment(y0: y0, y1: mid)
segments[index].rightChildIndex = makeSegment(y0: mid, y1: y1)
}
return index
}
private func adjustInsertionCountsOfSegmentTree(atIndex i: Int, by delta: Int, for side: Side) {
var segment = segments[i]
if side.fullyContains(segment) {
segment.insertions += delta
} else {
segment.withChildrenThatOverlap(side) { adjustInsertionCountsOfSegmentTree(atIndex: $0, by: delta, for: side) }
}
segment.status = uncachedStatus(of: segment)
segments[i] = segment
}
private func uncachedStatus(of segment: Segment) -> Segment.Status {
if segment.insertions > 0 { return .full }
if let l = segment.leftChildIndex, let r = segment.rightChildIndex {
return segments[l].status == .empty && segments[r].status == .empty ? .empty : .partial
}
return .empty
}
private func pushEmptySegmentsOfSegmentTree(atIndex i: Int, thatOverlap side: Side) {
let segment = segments[i]
switch segment.status {
case .empty where side.fullyContains(segment):
if let top = stack.last, segment.y0 == top.1 {
// segment.y0 == prior segment.y1, so merge.
stack[stack.count - 1] = (top.0, segment.y1)
} else {
stack.append((segment.y0, segment.y1))
}
case .partial, .empty:
segment.withChildrenThatOverlap(side) { pushEmptySegmentsOfSegmentTree(atIndex: $0, thatOverlap: side) }
case .full: break
}
}
private func makeSide(direction: VerticalDirection, rect: CGRect) -> Side {
let x: Int
switch direction {
case .down: x = indexOfX[rect.minX]!
case .up: x = indexOfX[rect.maxX]!
}
return Side(x: x, direction: direction, y0: indexOfY[rect.minY]!, y1: indexOfY[rect.maxY]!)
}
private func convertStackToPhase2Sides(atX x: Int, direction: VerticalDirection) {
guard stack.count > 0 else { return }
let gx = xs[x]
switch direction {
case .up:
for (y0, y1) in stack {
phase2.addVerticalSide(atX: gx, fromY: ys[y0], toY: ys[y1])
}
case .down:
for (y0, y1) in stack {
phase2.addVerticalSide(atX: gx, fromY: ys[y1], toY: ys[y0])
}
}
stack.removeAll(keepingCapacity: true)
}
}
fileprivate class AlgorithmPhase2 {
init(cornerRadius: CGFloat) {
self.cornerRadius = cornerRadius
}
let cornerRadius: CGFloat
func addVerticalSide(atX x: CGFloat, fromY y0: CGFloat, toY y1: CGFloat) {
verticalSides.append(VerticalSide(x: x, y0: y0, y1: y1))
}
func makePath() -> CGPath {
verticalSides.sort(by: { (a, b) in
if a.x < b.x { return true }
if a.x > b.x { return false }
return a.y0 < b.y0
})
var vertexes: [Vertex] = []
for (i, side) in verticalSides.enumerated() {
vertexes.append(Vertex(x: side.x, y0: side.y0, y1: side.y1, sideIndex: i, representsEnd: false))
vertexes.append(Vertex(x: side.x, y0: side.y1, y1: side.y0, sideIndex: i, representsEnd: true))
}
vertexes.sort(by: { (a, b) in
if a.y0 < b.y0 { return true }
if a.y0 > b.y0 { return false }
return a.x < b.x
})
for i in stride(from: 0, to: vertexes.count, by: 2) {
let v0 = vertexes[i]
let v1 = vertexes[i+1]
let startSideIndex: Int
let endSideIndex: Int
if v0.representsEnd {
startSideIndex = v0.sideIndex
endSideIndex = v1.sideIndex
} else {
startSideIndex = v1.sideIndex
endSideIndex = v0.sideIndex
}
precondition(verticalSides[startSideIndex].nextIndex == -1)
verticalSides[startSideIndex].nextIndex = endSideIndex
}
let path = CGMutablePath()
for i in verticalSides.indices where !verticalSides[i].emitted {
addLoop(startingAtSideIndex: i, to: path)
}
return path.copy()!
}
private var verticalSides: [VerticalSide] = []
private struct VerticalSide {
var x: CGFloat
var y0: CGFloat
var y1: CGFloat
var nextIndex = -1
var emitted = false
var isDown: Bool { return y1 < y0 }
var startPoint: CGPoint { return CGPoint(x: x, y: y0) }
var midPoint: CGPoint { return CGPoint(x: x, y: 0.5 * (y0 + y1)) }
var endPoint: CGPoint { return CGPoint(x: x, y: y1) }
init(x: CGFloat, y0: CGFloat, y1: CGFloat) {
self.x = x
self.y0 = y0
self.y1 = y1
}
}
private struct Vertex {
var x: CGFloat
var y0: CGFloat
var y1: CGFloat
var sideIndex: Int
var representsEnd: Bool
}
private func addLoop(startingAtSideIndex startIndex: Int, to path: CGMutablePath) {
var point = verticalSides[startIndex].midPoint
path.move(to: point)
var fromIndex = startIndex
repeat {
let toIndex = verticalSides[fromIndex].nextIndex
let horizontalMidpoint = CGPoint(x: 0.5 * (verticalSides[fromIndex].x + verticalSides[toIndex].x), y: verticalSides[fromIndex].y1)
path.addCorner(from: point, toward: verticalSides[fromIndex].endPoint, to: horizontalMidpoint, maxRadius: cornerRadius)
let nextPoint = verticalSides[toIndex].midPoint
path.addCorner(from: horizontalMidpoint, toward: verticalSides[toIndex].startPoint, to: nextPoint, maxRadius: cornerRadius)
verticalSides[fromIndex].emitted = true
fromIndex = toIndex
point = nextPoint
} while fromIndex != startIndex
path.closeSubpath()
}
}
fileprivate extension CGMutablePath {
func addCorner(from start: CGPoint, toward corner: CGPoint, to end: CGPoint, maxRadius: CGFloat) {
let radius = min(maxRadius, min(abs(start.x - end.x), abs(start.y - end.y)))
addArc(tangent1End: corner, tangent2End: end, radius: radius)
}
}
fileprivate enum VerticalDirection: Int {
case down = 0
case up = 1
}
With this, I can paint the rounded background you want in my view controller:
private func setHighlightPath() {
let textLayer = textView.layer
let textContainerInset = textView.textContainerInset
let uiInset = CGFloat(insetSlider.value)
let radius = CGFloat(radiusSlider.value)
let highlightLayer = self.highlightLayer
let layout = textView.layoutManager
let range = NSMakeRange(0, layout.numberOfGlyphs)
var rects = [CGRect]()
layout.enumerateLineFragments(forGlyphRange: range) { (_, usedRect, _, _, _) in
if usedRect.width > 0 && usedRect.height > 0 {
var rect = usedRect
rect.origin.x += textContainerInset.left
rect.origin.y += textContainerInset.top
rect = highlightLayer.convert(rect, from: textLayer)
rect = rect.insetBy(dx: uiInset, dy: uiInset)
rects.append(rect)
}
}
highlightLayer.path = CGPath.makeUnion(of: rects, cornerRadius: radius)
}
Im pasting in the wrapper class I made for this. Thanks for the algorithm rob mayoff it works great.
Limitations that I'm aware of:
If you use dynamic colors, this class is not aware of traitcollection and userinterface style changes. Highlight is cgColor so it will not adopt. So your .label color for the highlight will stay either .white or .black for example after interface style changed. Handle it outside with an update.
Just wrote this quickly without testing too many scenarios. Insets and radius auto calculated if not specified to fit the font size but not aware or line spacing and other variables.
Generally not tested heavily so check if it fits your purpose.
This being a View and a textView on it, you need to lay it out with frame or constraints for width and height. No intrinsic content size. If you want to fiddle with that, please do and post the intrinsicContentSize function.
Editing and selection for the textView is turned off. textView is public; Turn editing back on if you need it.
convenience init / update signature:
init(text:String? = nil,
font:UIFont? = nil,
textColor:UIColor? = nil,
highlightColor:UIColor? = nil,
inset:CGFloat? = nil,
radius:CGFloat? = nil)
usage examples:
let stamp = Stamp()
let stamp = Stamp(text: "Whatever\nneeds to be\nstamped.")
let stamp = Stamp(text: "Placeholder that has no line breaks but wraps anyway.")
stamp.update(text: "Smaller Version",
font: UIFont.systemFont(ofSize: 15, weight: .regular),
textColor: .label,
highlightColor:.purple)
Just make a new file and paste in this class. Use as described.
import UIKit
import CoreGraphics
class Stamp: UIView, UITextViewDelegate {
var textView = UITextView()
private var text:String = "Place holder\nline\nbroken Stamp."
private var highlightLayer = CAShapeLayer()
private var highlightColor:CGColor = UIColor.systemOrange.cgColor
private var textColor:UIColor = UIColor.label
private var font:UIFont = UIFont.systemFont(ofSize: 35, weight: .bold)
private var inset:CGFloat = 1
private var radius:CGFloat = 1
override init(frame: CGRect) {
super.init(frame: frame)
textView.delegate = self
textView.isEditable = false
textView.isSelectable = false
textView.font = self.font
self.inset = -font.pointSize / 5
self.radius = font.pointSize / 4
self.textView.text = self.text
self.textView.textAlignment = .center
self.textView.backgroundColor = .clear
highlightLayer.backgroundColor = nil
highlightLayer.strokeColor = nil
self.layer.insertSublayer(highlightLayer, at: 0)
highlightLayer.fillColor = self.highlightColor
addSubview(textView)
textView.fillSuperview()
}
convenience init(text:String? = nil,
font:UIFont? = nil,
textColor:UIColor? = nil,
highlightColor:UIColor? = nil,
inset:CGFloat? = nil,
radius:CGFloat? = nil) {
self.init(frame: .zero)
self.update(text: text,
font: font,
textColor: textColor,
highlightColor: highlightColor,
inset: inset,
radius: radius)
}
func update(text:String? = nil,
font:UIFont? = nil,
textColor:UIColor? = nil,
highlightColor:UIColor? = nil,
inset:CGFloat? = nil,
radius:CGFloat? = nil){
if let text = text { self.text = text }
if let font = font { self.font = font }
if let textColor = textColor { self.textColor = textColor }
if let highlightColor = highlightColor { self.highlightColor = highlightColor.cgColor }
self.inset = inset ?? -self.font.pointSize / 5
self.radius = radius ?? self.font.pointSize / 4
self.textView.text = text
self.textView.textColor = self.textColor
self.textView.font = self.font
highlightLayer.fillColor = self.highlightColor
// this will re-draw the highlight
setHighlightPath()
}
override func layoutSubviews() {
super.layoutSubviews()
highlightLayer.frame = self.bounds
self.setHighlightPath()
}
func textViewDidChange(_ textView: UITextView) {
setHighlightPath()
}
private func setHighlightPath() {
let textLayer = textView.layer
let textContainerInset = textView.textContainerInset
let uiInset = CGFloat(inset)
let radius = CGFloat(radius)
let highlightLayer = self.highlightLayer
let layout = textView.layoutManager
let range = NSMakeRange(0, layout.numberOfGlyphs)
var rects = [CGRect]()
layout.enumerateLineFragments(forGlyphRange: range) { (_, usedRect, _, _, _) in
if usedRect.width > 0 && usedRect.height > 0 {
var rect = usedRect
rect.origin.x += textContainerInset.left
rect.origin.y += textContainerInset.top
rect = highlightLayer.convert(rect, from: textLayer)
rect = rect.insetBy(dx: uiInset, dy: uiInset)
rects.append(rect)
}
}
highlightLayer.path = CGPath.makeUnion(of: rects, cornerRadius: radius)
}
// Bojler
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension CGPath {
static func makeUnion(of rects: [CGRect], cornerRadius: CGFloat) -> CGPath {
let phase2 = AlgorithmPhase2(cornerRadius: cornerRadius)
_ = AlgorithmPhase1(rects: rects, phase2: phase2)
return phase2.makePath()
}
}
fileprivate func swapped<A, B>(_ pair: (A, B)) -> (B, A) { return (pair.1, pair.0) }
fileprivate class AlgorithmPhase1 {
init(rects: [CGRect], phase2: AlgorithmPhase2) {
self.phase2 = phase2
xs = Array(Set(rects.map({ $0.origin.x})).union(rects.map({ $0.origin.x + $0.size.width }))).sorted()
indexOfX = [CGFloat:Int](uniqueKeysWithValues: xs.enumerated().map(swapped))
ys = Array(Set(rects.map({ $0.origin.y})).union(rects.map({ $0.origin.y + $0.size.height }))).sorted()
indexOfY = [CGFloat:Int](uniqueKeysWithValues: ys.enumerated().map(swapped))
segments.reserveCapacity(2 * ys.count)
_ = makeSegment(y0: 0, y1: ys.count - 1)
let sides = (rects.map({ makeSide(direction: .down, rect: $0) }) + rects.map({ makeSide(direction: .up, rect: $0)})).sorted()
var priorX = 0
var priorDirection = VerticalDirection.down
for side in sides {
if side.x != priorX || side.direction != priorDirection {
convertStackToPhase2Sides(atX: priorX, direction: priorDirection)
priorX = side.x
priorDirection = side.direction
}
switch priorDirection {
case .down:
pushEmptySegmentsOfSegmentTree(atIndex: 0, thatOverlap: side)
adjustInsertionCountsOfSegmentTree(atIndex: 0, by: 1, for: side)
case .up:
adjustInsertionCountsOfSegmentTree(atIndex: 0, by: -1, for: side)
pushEmptySegmentsOfSegmentTree(atIndex: 0, thatOverlap: side)
}
}
convertStackToPhase2Sides(atX: priorX, direction: priorDirection)
}
private let phase2: AlgorithmPhase2
private let xs: [CGFloat]
private let indexOfX: [CGFloat: Int]
private let ys: [CGFloat]
private let indexOfY: [CGFloat: Int]
private var segments: [Segment] = []
private var stack: [(Int, Int)] = []
private struct Segment {
var y0: Int
var y1: Int
var insertions = 0
var status = Status.empty
var leftChildIndex: Int?
var rightChildIndex: Int?
var mid: Int { return (y0 + y1 + 1) / 2 }
func withChildrenThatOverlap(_ side: Side, do body: (_ childIndex: Int) -> ()) {
if side.y0 < mid, let l = leftChildIndex { body(l) }
if mid < side.y1, let r = rightChildIndex { body(r) }
}
init(y0: Int, y1: Int) {
self.y0 = y0
self.y1 = y1
}
enum Status {
case empty
case partial
case full
}
}
private struct /*Vertical*/Side: Comparable {
var x: Int
var direction: VerticalDirection
var y0: Int
var y1: Int
func fullyContains(_ segment: Segment) -> Bool {
return y0 <= segment.y0 && segment.y1 <= y1
}
static func ==(lhs: Side, rhs: Side) -> Bool {
return lhs.x == rhs.x && lhs.direction == rhs.direction && lhs.y0 == rhs.y0 && lhs.y1 == rhs.y1
}
static func <(lhs: Side, rhs: Side) -> Bool {
if lhs.x < rhs.x { return true }
if lhs.x > rhs.x { return false }
if lhs.direction.rawValue < rhs.direction.rawValue { return true }
if lhs.direction.rawValue > rhs.direction.rawValue { return false }
if lhs.y0 < rhs.y0 { return true }
if lhs.y0 > rhs.y0 { return false }
return lhs.y1 < rhs.y1
}
}
private func makeSegment(y0: Int, y1: Int) -> Int {
let index = segments.count
let segment: Segment = Segment(y0: y0, y1: y1)
segments.append(segment)
if y1 - y0 > 1 {
let mid = segment.mid
segments[index].leftChildIndex = makeSegment(y0: y0, y1: mid)
segments[index].rightChildIndex = makeSegment(y0: mid, y1: y1)
}
return index
}
private func adjustInsertionCountsOfSegmentTree(atIndex i: Int, by delta: Int, for side: Side) {
var segment = segments[i]
if side.fullyContains(segment) {
segment.insertions += delta
} else {
segment.withChildrenThatOverlap(side) { adjustInsertionCountsOfSegmentTree(atIndex: $0, by: delta, for: side) }
}
segment.status = uncachedStatus(of: segment)
segments[i] = segment
}
private func uncachedStatus(of segment: Segment) -> Segment.Status {
if segment.insertions > 0 { return .full }
if let l = segment.leftChildIndex, let r = segment.rightChildIndex {
return segments[l].status == .empty && segments[r].status == .empty ? .empty : .partial
}
return .empty
}
private func pushEmptySegmentsOfSegmentTree(atIndex i: Int, thatOverlap side: Side) {
let segment = segments[i]
switch segment.status {
case .empty where side.fullyContains(segment):
if let top = stack.last, segment.y0 == top.1 {
// segment.y0 == prior segment.y1, so merge.
stack[stack.count - 1] = (top.0, segment.y1)
} else {
stack.append((segment.y0, segment.y1))
}
case .partial, .empty:
segment.withChildrenThatOverlap(side) { pushEmptySegmentsOfSegmentTree(atIndex: $0, thatOverlap: side) }
case .full: break
}
}
private func makeSide(direction: VerticalDirection, rect: CGRect) -> Side {
let x: Int
switch direction {
case .down: x = indexOfX[rect.minX]!
case .up: x = indexOfX[rect.maxX]!
}
return Side(x: x, direction: direction, y0: indexOfY[rect.minY]!, y1: indexOfY[rect.maxY]!)
}
private func convertStackToPhase2Sides(atX x: Int, direction: VerticalDirection) {
guard stack.count > 0 else { return }
let gx = xs[x]
switch direction {
case .up:
for (y0, y1) in stack {
phase2.addVerticalSide(atX: gx, fromY: ys[y0], toY: ys[y1])
}
case .down:
for (y0, y1) in stack {
phase2.addVerticalSide(atX: gx, fromY: ys[y1], toY: ys[y0])
}
}
stack.removeAll(keepingCapacity: true)
}
}
fileprivate class AlgorithmPhase2 {
init(cornerRadius: CGFloat) {
self.cornerRadius = cornerRadius
}
let cornerRadius: CGFloat
func addVerticalSide(atX x: CGFloat, fromY y0: CGFloat, toY y1: CGFloat) {
verticalSides.append(VerticalSide(x: x, y0: y0, y1: y1))
}
func makePath() -> CGPath {
verticalSides.sort(by: { (a, b) in
if a.x < b.x { return true }
if a.x > b.x { return false }
return a.y0 < b.y0
})
var vertexes: [Vertex] = []
for (i, side) in verticalSides.enumerated() {
vertexes.append(Vertex(x: side.x, y0: side.y0, y1: side.y1, sideIndex: i, representsEnd: false))
vertexes.append(Vertex(x: side.x, y0: side.y1, y1: side.y0, sideIndex: i, representsEnd: true))
}
vertexes.sort(by: { (a, b) in
if a.y0 < b.y0 { return true }
if a.y0 > b.y0 { return false }
return a.x < b.x
})
for i in stride(from: 0, to: vertexes.count, by: 2) {
let v0 = vertexes[i]
let v1 = vertexes[i+1]
let startSideIndex: Int
let endSideIndex: Int
if v0.representsEnd {
startSideIndex = v0.sideIndex
endSideIndex = v1.sideIndex
} else {
startSideIndex = v1.sideIndex
endSideIndex = v0.sideIndex
}
precondition(verticalSides[startSideIndex].nextIndex == -1)
verticalSides[startSideIndex].nextIndex = endSideIndex
}
let path = CGMutablePath()
for i in verticalSides.indices where !verticalSides[i].emitted {
addLoop(startingAtSideIndex: i, to: path)
}
return path.copy()!
}
private var verticalSides: [VerticalSide] = []
private struct VerticalSide {
var x: CGFloat
var y0: CGFloat
var y1: CGFloat
var nextIndex = -1
var emitted = false
var isDown: Bool { return y1 < y0 }
var startPoint: CGPoint { return CGPoint(x: x, y: y0) }
var midPoint: CGPoint { return CGPoint(x: x, y: 0.5 * (y0 + y1)) }
var endPoint: CGPoint { return CGPoint(x: x, y: y1) }
init(x: CGFloat, y0: CGFloat, y1: CGFloat) {
self.x = x
self.y0 = y0
self.y1 = y1
}
}
private struct Vertex {
var x: CGFloat
var y0: CGFloat
var y1: CGFloat
var sideIndex: Int
var representsEnd: Bool
}
private func addLoop(startingAtSideIndex startIndex: Int, to path: CGMutablePath) {
var point = verticalSides[startIndex].midPoint
path.move(to: point)
var fromIndex = startIndex
repeat {
let toIndex = verticalSides[fromIndex].nextIndex
let horizontalMidpoint = CGPoint(x: 0.5 * (verticalSides[fromIndex].x + verticalSides[toIndex].x), y: verticalSides[fromIndex].y1)
path.addCorner(from: point, toward: verticalSides[fromIndex].endPoint, to: horizontalMidpoint, maxRadius: cornerRadius)
let nextPoint = verticalSides[toIndex].midPoint
path.addCorner(from: horizontalMidpoint, toward: verticalSides[toIndex].startPoint, to: nextPoint, maxRadius: cornerRadius)
verticalSides[fromIndex].emitted = true
fromIndex = toIndex
point = nextPoint
} while fromIndex != startIndex
path.closeSubpath()
}
}
fileprivate extension CGMutablePath {
func addCorner(from start: CGPoint, toward corner: CGPoint, to end: CGPoint, maxRadius: CGFloat) {
let radius = min(maxRadius, min(abs(start.x - end.x), abs(start.y - end.y)))
addArc(tangent1End: corner, tangent2End: end, radius: radius)
}
}
fileprivate enum VerticalDirection: Int {
case down = 0
case up = 1
}

CGDataProvider returning null in Swift 4

I'm trying to fix a problem in SwiftColorPicker (by Matthias Schlemm) that popped up with Swift 4.0. I'm going to include the whole PickerImage class, so you can see the full context.
import UIKit
import ImageIO
open class PickerImage {
var provider:CGDataProvider!
var imageSource:CGImageSource?
var image:UIImage?
var mutableData:CFMutableData
var width:Int
var height:Int
fileprivate func createImageFromData(_ width:Int, height:Int) {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
let provider = CGDataProvider(data: mutableData)
imageSource = CGImageSourceCreateWithDataProvider(provider!, nil)
let cgimg = CGImage(width: Int(width), height: Int(height), bitsPerComponent: Int(8), bitsPerPixel: Int(32), bytesPerRow: Int(width) * Int(4),
space: colorSpace, bitmapInfo: bitmapInfo, provider: provider!, decode: nil, shouldInterpolate: true, intent: CGColorRenderingIntent.defaultIntent)
image = UIImage(cgImage: cgimg!)
}
func changeSize(_ width:Int, height:Int) {
self.width = width
self.height = height
let size:Int = width * height * 4
CFDataSetLength(mutableData, size)
createImageFromData(width, height: height)
}
init(width:Int, height:Int) {
self.width = width
self.height = height
let size:Int = width * height * 4
mutableData = CFDataCreateMutable(kCFAllocatorDefault, size)
createImageFromData(width, height: height)
}
open func writeColorData(_ h:CGFloat, a:CGFloat) {
let d = CFDataGetMutableBytePtr(self.mutableData)
if width == 0 || height == 0 {
return
}
var i:Int = 0
let h360:CGFloat = ((h == 1 ? 0 : h) * 360) / 60.0
let sector:Int = Int(floor(h360))
let f:CGFloat = h360 - CGFloat(sector)
let f1:CGFloat = 1.0 - f
var p:CGFloat = 0.0
var q:CGFloat = 0.0
var t:CGFloat = 0.0
let sd:CGFloat = 1.0 / CGFloat(width)
let vd:CGFloat = 1 / CGFloat(height)
var double_s:CGFloat = 0
var pf:CGFloat = 0
let v_range = 0..<height
let s_range = 0..<width
for v in v_range {
pf = 255 * CGFloat(v) * vd
for s in s_range {
i = (v * width + s) * 4
d?[i] = UInt8(255)
if s == 0 {
q = pf
d?[i+1] = UInt8(q)
d?[i+2] = UInt8(q)
d?[i+3] = UInt8(q)
continue
}
double_s = CGFloat(s) * sd
p = pf * (1.0 - double_s)
q = pf * (1.0 - double_s * f)
t = pf * ( 1.0 - double_s * f1)
switch(sector) {
case 0:
d?[i+1] = UInt8(pf)
d?[i+2] = UInt8(t)
d?[i+3] = UInt8(p)
case 1:
d?[i+1] = UInt8(q)
d?[i+2] = UInt8(pf)
d?[i+3] = UInt8(p)
case 2:
d?[i+1] = UInt8(p)
d?[i+2] = UInt8(pf)
d?[i+3] = UInt8(t)
case 3:
d?[i+1] = UInt8(p)
d?[i+2] = UInt8(q)
d?[i+3] = UInt8(pf)
case 4:
d?[i+1] = UInt8(t)
d?[i+2] = UInt8(p)
d?[i+3] = UInt8(pf)
default:
d?[i+1] = UInt8(pf)
d?[i+2] = UInt8(p)
d?[i+3] = UInt8(q)
}
}
}
}
}
In createImageFromData, the line
let provider = CGDataProvider(data: mutableData)
is returning a nil value, which, of course, causes the following line to crash. This was working fine in Swift 3.
Here are the values in debugger:
Dealing with memory allocation is a bit beyond my current skillset, so I'm struggling with what's actually going on here. Has anything related to this changed in Swift 4.0 that would cause the CGDataProvider call to return a nil value?
Edit:
Here is the ColorPicker class that initializes the PickerImage objects.
import UIKit
import ImageIO
open class ColorPicker: UIView {
fileprivate var pickerImage1:PickerImage?
fileprivate var pickerImage2:PickerImage?
fileprivate var image:UIImage?
fileprivate var data1Shown = false
fileprivate lazy var opQueue:OperationQueue = {return OperationQueue()}()
fileprivate var lock:NSLock = NSLock()
fileprivate var rerender = false
open var onColorChange:((_ color:UIColor, _ finished:Bool)->Void)? = nil
open var a:CGFloat = 1 {
didSet {
if a < 0 || a > 1 {
a = max(0, min(1, a))
}
}
}
open var h:CGFloat = 0 { // // [0,1]
didSet {
if h > 1 || h < 0 {
h = max(0, min(1, h))
}
renderBitmap()
setNeedsDisplay()
}
}
fileprivate var currentPoint:CGPoint = CGPoint.zero
open func saturationFromCurrentPoint() -> CGFloat {
return (1 / bounds.width) * currentPoint.x
}
open func brigthnessFromCurrentPoint() -> CGFloat {
return (1 / bounds.height) * currentPoint.y
}
open var color:UIColor {
set(value) {
var hue:CGFloat = 1
var saturation:CGFloat = 1
var brightness:CGFloat = 1
var alpha:CGFloat = 1
value.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
a = alpha
if hue != h || pickerImage1 === nil {
self.h = hue
}
currentPoint = CGPoint(x: saturation * bounds.width, y: brightness * bounds.height)
self.setNeedsDisplay()
}
get {
return UIColor(hue: h, saturation: saturationFromCurrentPoint(), brightness: brigthnessFromCurrentPoint(), alpha: a)
}
}
public override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() {
isUserInteractionEnabled = true
clipsToBounds = false
self.addObserver(self, forKeyPath: "bounds", options: [NSKeyValueObservingOptions.new, NSKeyValueObservingOptions.initial], context: nil)
}
deinit {
self.removeObserver(self, forKeyPath: "bounds")
}
open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "bounds" {
if let pImage1 = pickerImage1 {
pImage1.changeSize(Int(self.bounds.width), height: Int(self.bounds.height))
}
if let pImage2 = pickerImage2 {
pImage2.changeSize(Int(self.bounds.width), height: Int(self.bounds.height))
}
renderBitmap()
self.setNeedsDisplay()
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first! as UITouch
handleTouche(touch, ended: false)
}
open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first! as UITouch
handleTouche(touch, ended: false)
}
open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first! as UITouch
handleTouche(touch, ended: true)
}
fileprivate func handleColorChange(_ color:UIColor, changing:Bool) {
if color !== self.color {
if let handler = onColorChange {
handler(color, !changing)
}
setNeedsDisplay()
}
}
fileprivate func handleTouche(_ touch:UITouch, ended:Bool) {
// set current point
let point = touch.location(in: self)
if self.bounds.contains(point) {
currentPoint = point
} else {
let x:CGFloat = min(bounds.width, max(0, point.x))
let y:CGFloat = min(bounds.width, max(0, point.y))
currentPoint = CGPoint(x: x, y: y)
}
handleColorChange(pointToColor(point), changing: !ended)
}
fileprivate func pointToColor(_ point:CGPoint) ->UIColor {
let s:CGFloat = min(1, max(0, (1.0 / bounds.width) * point.x))
let b:CGFloat = min(1, max(0, (1.0 / bounds.height) * point.y))
return UIColor(hue: h, saturation: s, brightness: b, alpha:a)
}
fileprivate func renderBitmap() {
if self.bounds.isEmpty {
return
}
if !lock.try() {
rerender = true
return
}
rerender = false
if pickerImage1 == nil {
self.pickerImage1 = PickerImage(width: Int(bounds.width), height: Int(bounds.height))
self.pickerImage2 = PickerImage(width: Int(bounds.width), height: Int(bounds.height))
}
opQueue.addOperation { () -> Void in
// Write colors to data array
if self.data1Shown { self.pickerImage2!.writeColorData(self.h, a:self.a) }
else { self.pickerImage1!.writeColorData(self.h, a:self.a)}
// flip images
// self.image = self.data1Shown ? self.pickerImage2!.image! : self.pickerImage1!.image!
self.data1Shown = !self.data1Shown
// make changes visible
OperationQueue.main.addOperation({ () -> Void in
self.setNeedsDisplay()
self.lock.unlock()
if self.rerender {
self.renderBitmap()
}
})
}
}
open override func draw(_ rect: CGRect) {
if let img = image {
img.draw(in: rect)
}
//// Oval Drawing
let ovalPath = UIBezierPath(ovalIn: CGRect(x: currentPoint.x - 5, y: currentPoint.y - 5, width: 10, height: 10))
UIColor.white.setStroke()
ovalPath.lineWidth = 1
ovalPath.stroke()
//// Oval 2 Drawing
let oval2Path = UIBezierPath(ovalIn: CGRect(x: currentPoint.x - 4, y: currentPoint.y - 4, width: 8, height: 8))
UIColor.black.setStroke()
oval2Path.lineWidth = 1
oval2Path.stroke()
}
}
It's not a problem of Swift 4, but a problem of iOS 11. You may find your code works on iOS 10 simulator.
The original code seemingly works in iOS 10, depending on just a luck.
In this part of the code:
init(width:Int, height:Int) {
self.width = width
self.height = height
let size:Int = width * height * 4
mutableData = CFDataCreateMutable(kCFAllocatorDefault, size)
createImageFromData(width, height: height)
}
The property mutableData is initialized with a CFMutableData of capacity: size, and empty (that is, content-less).
And in iOS 11, the initializer CGDataProvider.init(data:) rejects an empty CFData as it should not be empty as a data provider.
A quick fix would be something like this:
init(width:Int, height:Int) {
self.width = width
self.height = height
let size:Int = width * height * 4
mutableData = CFDataCreateMutable(kCFAllocatorDefault, size)
CFDataSetLength(mutableData, size) //<-set the length of the data
createImageFromData(width, height: height)
}
But I'm not sure other parts of the code would work as expected in iOS 11.
The problem is you added let to that line and it creates a new variable call provider within the function createImageFromData. That way, it's never passed to the provider parameter of the class and then, it's always null. Just remove the let and it should work properly.
fileprivate func createImageFromData(_ width:Int, height:Int) {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
provider = CGDataProvider(data: mutableData)
imageSource = CGImageSourceCreateWithDataProvider(provider!, nil)
let cgimg = CGImage(width: Int(width), height: Int(height), bitsPerComponent: Int(8), bitsPerPixel: Int(32), bytesPerRow: Int(width) * Int(4),
space: colorSpace, bitmapInfo: bitmapInfo, provider: provider!, decode: nil, shouldInterpolate: true, intent: CGColorRenderingIntent.defaultIntent)
image = UIImage(cgImage: cgimg!)
}

Pie Chart slices in swift

I'm trying to make a pie chart. Actually it's done, but I would like to get some values, and each value should be a slice of the pie. The only thing I could do is fill the pie with a slider. How can I make different slices with different colors for some values?
Here is my code for drawing the chart (I got here in stack) :
import UIKit
#IBDesignable class ChartView: UIView {
#IBInspectable var progress : Double = 0.0 {
didSet {
self.setNeedsDisplay()
}
}
#IBInspectable var noProgress : Double = 0.0 {
didSet {
self.setNeedsDisplay()
}
}
required init(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)
self.contentMode = .Redraw
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
self.contentMode = .Redraw
}
override func drawRect(rect: CGRect) {
let color = UIColor.blueColor().CGColor
let lineWidth : CGFloat = 2.0
// Calculate box with insets
let margin: CGFloat = lineWidth
let box0 = CGRectInset(self.bounds, margin, margin)
let side : CGFloat = min(box0.width, box0.height)
let box = CGRectMake((self.bounds.width-side)/2, (self.bounds.height-side)/2,side,side)
let ctx = UIGraphicsGetCurrentContext()
// Draw outline
CGContextBeginPath(ctx)
CGContextSetStrokeColorWithColor(ctx, UIColor.blackColor().CGColor)
CGContextSetLineWidth(ctx, lineWidth)
CGContextAddEllipseInRect(ctx, box)
CGContextClosePath(ctx)
CGContextStrokePath(ctx)
// Draw arc
let delta : CGFloat = -CGFloat(M_PI_2)
let radius : CGFloat = min(box.width, box.height)/2.0
func prog_to_rad(p: Double) -> CGFloat {
let rad = CGFloat((p * M_PI)/180)
return rad
}
func draw_arc(s: CGFloat, e: CGFloat, color: CGColor) {
CGContextBeginPath(ctx)
CGContextMoveToPoint(ctx, box.midX, box.midY)
CGContextSetFillColorWithColor(ctx, color)
CGContextAddArc(ctx, box.midX, box.midY, radius-lineWidth/2, s, e, 0)
CGContextClosePath(ctx)
CGContextFillPath(ctx)
}
if progress > 0 {
let s = prog_to_rad(noProgress * 360/100)
let e = prog_to_rad(progress * 360/100)
draw_arc(s, e, color)
}
}
}
And here is my ViewController:
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var pieChartView: ChartView!
#IBOutlet weak var slider: UISlider!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
#IBAction func setValue(sender: UISlider) {
pieChartView.progress = Double(sender.value)
}
}
This code is from my blogpost, it uses CAShapeLayer and UIBezierPath. You can create any number of segments with whichever choice of colour you like.
extension CGFloat {
func radians() -> CGFloat {
let b = CGFloat(M_PI) * (self/180)
return b
}
}
extension UIBezierPath {
convenience init(circleSegmentCenter center:CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle:CGFloat)
{
self.init()
self.moveToPoint(CGPointMake(center.x, center.y))
self.addArcWithCenter(center, radius:radius, startAngle:startAngle.radians(), endAngle: endAngle.radians(), clockwise:true)
self.closePath()
}
}
func pieChart(pieces:[(UIBezierPath, UIColor)], viewRect:CGRect) -> UIView {
var layers = [CAShapeLayer]()
for p in pieces {
let layer = CAShapeLayer()
layer.path = p.0.CGPath
layer.fillColor = p.1.CGColor
layer.strokeColor = UIColor.whiteColor().CGColor
layers.append(layer)
}
let view = UIView(frame: viewRect)
for l in layers {
view.layer.addSublayer(l)
}
return view
}
let rectSize = CGRectMake(0,0,400,400)
let centrePointOfChart = CGPointMake(CGRectGetMidX(rectSize),CGRectGetMidY(rectSize))
let radius:CGFloat = 100
let piePieces = [(UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 250, endAngle: 360),UIColor.brownColor()), (UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 0, endAngle: 200),UIColor.orangeColor()), (UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 200, endAngle: 250),UIColor.lightGrayColor())]
pieChart(piePieces, viewRect: CGRectMake(0,0,400,400))
You posted a bunch of code that appears to draw a single pie chart "slice" in a single color.
Are you saying that you don't know how to make it draw an entire pie, with slices of different sizes, and that you don't know how to make each slice a different color?
It sounds to me like you are copy/pasting code you got from somewhere and have no idea how it works. How about you walk us through what your code does and give us a clearer idea of where you're stuck?
We're not here to take your copy/paste code and modify it for you to make it meet your requirements. Sounds like custom development to me. I don't know about the other posters on this board, but I get paid for that.
As it happens I've written a development blog post that includes a sample app that generates pie charts in Swift. You can see it here:
http://wareto.com/swift-piecharts
Instead of overriding drawRect like the code you posted, it creates a CAShapeLayer that holds the pie chart. It manages a pie chart with a variable number of "slices", and will either change the arc of each slice, the radius, or both.
It is not set up to make each slice a different color. For that you'd have to modify it to use separate shape layers for each slice, which would be a fairly big structural change to the program.
It does at least show you how to draw a pie chart in Swift for iOS:
Below Code is useful for Pie Chart Slice space in swift. Check out once
import UIKit
private extension CGFloat {
/// Formats the CGFloat to a maximum of 1 decimal place.
var formattedToOneDecimalPlace : String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter.string(from: NSNumber(value: self.native)) ?? "\(self)"
}
}
/// Defines a segment of the pie chart
struct Segment {
/// The color of the segment
var color : UIColor
/// The name of the segment
var name : String
/// The value of the segment
var value : CGFloat
}
class PieChartView: UIView {
/// An array of structs representing the segments of the pie chart
var segments = [Segment]() {
didSet {
totalValue = segments.reduce(0) { $0 + $1.value }
setupLabels()
setNeedsDisplay() // re-draw view when the values get set
layoutLabels();
} // re-draw view when the values get set
}
/// Defines whether the segment labels should be shown when drawing the pie chart
var showSegmentLabels = true {
didSet { setNeedsDisplay() }
}
/// Defines whether the segment labels will show the value of the segment in brackets
var showSegmentValueInLabel = false {
didSet { setNeedsDisplay() }
}
/// The font to be used on the segment labels
var segmentLabelFont = UIFont.systemFont(ofSize: 14) {
didSet {
textAttributes[NSAttributedStringKey.font] = segmentLabelFont
setNeedsDisplay()
}
}
private let paragraphStyle : NSParagraphStyle = {
var p = NSMutableParagraphStyle()
p.alignment = .center
return p.copy() as! NSParagraphStyle
}()
private lazy var textAttributes : [NSAttributedStringKey : NSObject] = {
return [NSAttributedStringKey.paragraphStyle : self.paragraphStyle, NSAttributedStringKey.font : self.segmentLabelFont]
}()
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false // when overriding drawRect, you must specify this to maintain transparency.
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
private var labels: [UILabel] = []
private var totalValue: CGFloat = 1;
override func draw(_ rect: CGRect) {
let anglePI2 = (CGFloat.pi * 2)
let center = CGPoint.init(x: bounds.size.width / 2, y: bounds.size.height / 2)
let radius = min(bounds.size.width, bounds.size.height) / 2;
let lineWidth: CGFloat = 1.5;
let ctx = UIGraphicsGetCurrentContext()
ctx?.setLineWidth(lineWidth)
var currentAngle: CGFloat = 0
if totalValue <= 0 {
totalValue = 1
}
let iRange = 0 ..< segments.count
for i in iRange {
let segment = segments[i]
// calculate percent
let percent = segment.value / totalValue
let angle = anglePI2 * percent
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - lineWidth, startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setFillColor(segment.color.cgColor)
ctx?.fillPath()
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - (lineWidth / 2), startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setStrokeColor(UIColor.white.cgColor)
ctx?.strokePath()
currentAngle += angle
}
}
override func layoutSubviews() {
super.layoutSubviews()
self.layoutLabels()
}
private func setupLabels() {
var diff = segments.count - labels.count;
if diff >= 0 {
for _ in 0 ..< diff {
let lbl = UILabel()
self.addSubview(lbl)
labels.append(lbl)
}
} else {
while diff != 0 {
var lbl: UILabel!
if labels.count <= 0 {
break;
}
lbl = labels.removeLast()
if lbl.superview != nil {
lbl.removeFromSuperview()
}
diff += 1;
}
}
for i in 0 ..< segments.count {
let lbl = labels[i]
lbl.textColor = UIColor.white
// Change here for your text display
// I currently display percent of each pies
lbl.text = "\(segments[i].value.formattedToOneDecimalPlace)%" //String.init(format: "%0.0f", segments[i].value)
lbl.font = UIFont.systemFont(ofSize: 14)
}
}
func layoutLabels() {
let anglePI2 = CGFloat.pi * 2
let center = CGPoint.init(x: bounds.size.width / 2, y: bounds.size.height / 2)
let radius = min(bounds.size.width / 2, bounds.size.height / 2) / 1.5
var currentAngle: CGFloat = 0;
let iRange = 0 ..< labels.count
for i in iRange {
let lbl = labels[i]
let percent = segments[i].value / totalValue
let intervalAngle = anglePI2 * percent;
lbl.frame = .zero;
lbl.sizeToFit()
let x = center.x + radius * cos(currentAngle + (intervalAngle / 2))
let y = center.y + radius * sin(currentAngle + (intervalAngle / 2))
lbl.center = CGPoint.init(x: x, y: y)
currentAngle += intervalAngle
}
}
}

Resources