I'm using the Swift Chart. I'd like to modify it to allow the user to select a range. The idea is to touch, swipe left/right, and then lift your finger. This should highlight the area swiped and provide a way to get the beginning and ending values of the swipe. I expect I'll need to modify the touchesBegan() and touchesEnded() events, but I don't know how.
Here's what I did to make this work:
I added range selection variables to the class
// Range selection
open var leftRangePoint: UITouch!
open var rightRangePoint: UITouch!
open var leftRangeLocation: CGFloat = 0
open var rightRangeLocation: CGFloat = 0
I modified touchesBegan()
leftRangePoint = touches.first!
leftRangeLocation = leftRangePoint.location(in: self).x
And added a routine to touchesEnded()
handleRangeTouchesEnded(touches, event: event)
Here's the full code:
// Chart.swift
//
// Created by Giampaolo Bellavite on 07/11/14.
// Copyright (c) 2014 Giampaolo Bellavite. All rights reserved.
import UIKit
public protocol ChartDelegate: class {
func didTouchChart(_ chart: Chart, indexes: [Int?], x: Float, left: CGFloat)
func didFinishTouchingChart(_ chart: Chart)
func didEndTouchingChart(_ chart: Chart)
}
typealias ChartPoint = (x: Float, y: Float)
public enum ChartLabelOrientation {
case horizontal
case vertical
}
#IBDesignable open class Chart: UIControl {
#IBInspectable
open var identifier: String?
open var series: [ChartSeries] = [] {
didSet {
setNeedsDisplay()
}
}
open var xLabels: [Float]?
open var xLabelsFormatter = { (labelIndex: Int, labelValue: Float) -> String in
String(Int(labelValue))
}
open var xLabelsTextAlignment: NSTextAlignment = .left
open var xLabelsOrientation: ChartLabelOrientation = .horizontal
open var xLabelsSkipLast: Bool = true
open var xLabelsSkipAll: Bool = true
open var yLabels: [Float]?
open var yLabelsFormatter = { (labelIndex: Int, labelValue: Float) -> String in
String(Int(labelValue))
}
open var yLabelsOnRightSide: Bool = false
open var labelFont: UIFont? = UIFont.systemFont(ofSize: 12)
#IBInspectable
open var labelColor: UIColor = UIColor.black
#IBInspectable
open var axesColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
#IBInspectable
open var gridColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
open var showXLabelsAndGrid: Bool = true
open var showYLabelsAndGrid: Bool = true
open var bottomInset: CGFloat = 20
open var topInset: CGFloat = 20
#IBInspectable
open var lineWidth: CGFloat = 2
weak open var delegate: ChartDelegate?
open var minX: Float?
open var minY: Float?
open var maxX: Float?
open var maxY: Float?
open var highlightLineColor = UIColor.gray
open var highlightLineWidth: CGFloat = 0.5
open var areaAlphaComponent: CGFloat = 0.1
open var leftRangePoint: UITouch!
open var rightRangePoint: UITouch!
open var leftRangeLocation: CGFloat = 0
open var rightRangeLocation: CGFloat = 0
fileprivate var highlightShapeLayer: CAShapeLayer!
fileprivate var layerStore: [CAShapeLayer] = []
fileprivate var drawingHeight: CGFloat!
fileprivate var drawingWidth: CGFloat!
fileprivate var min: ChartPoint!
fileprivate var max: ChartPoint!
typealias ChartLineSegment = [ChartPoint]
override public init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
convenience public init() {
self.init(frame: .zero)
commonInit()
}
private func commonInit() {
backgroundColor = UIColor.clear
contentMode = .redraw // redraw rects on bounds change
}
override open func draw(_ rect: CGRect) {
#if TARGET_INTERFACE_BUILDER
drawIBPlaceholder()
#else
drawChart()
#endif
}
open func add(_ series: ChartSeries) {
self.series.append(series)
}
open func add(_ series: [ChartSeries]) {
for s in series {
add(s)
}
}
open func removeSeriesAt(_ index: Int) {
series.remove(at: index)
}
open func removeAllSeries() {
series = []
}
open func valueForSeries(_ seriesIndex: Int, atIndex dataIndex: Int?) -> Float? {
if dataIndex == nil { return nil }
let series = self.series[seriesIndex] as ChartSeries
return series.data[dataIndex!].y
}
fileprivate func drawIBPlaceholder() {
let placeholder = UIView(frame: self.frame)
placeholder.backgroundColor = UIColor(red: 0.93, green: 0.93, blue: 0.93, alpha: 1)
let label = UILabel()
label.text = "Chart"
label.font = UIFont.systemFont(ofSize: 28)
label.textColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.2)
label.sizeToFit()
label.frame.origin.x += frame.width/2 - (label.frame.width / 2)
label.frame.origin.y += frame.height/2 - (label.frame.height / 2)
placeholder.addSubview(label)
addSubview(placeholder)
}
fileprivate func drawChart() {
drawingHeight = bounds.height - bottomInset - topInset
drawingWidth = bounds.width
let minMax = getMinMax()
min = minMax.min
max = minMax.max
highlightShapeLayer = nil
// Remove things before drawing, e.g. when changing orientation
for view in self.subviews {
view.removeFromSuperview()
}
for layer in layerStore {
layer.removeFromSuperlayer()
}
layerStore.removeAll()
// Draw content
for (index, series) in self.series.enumerated() {
// Separate each line in multiple segments over and below the x axis
let segments = Chart.segmentLine(series.data as ChartLineSegment, zeroLevel: series.colors.zeroLevel)
segments.forEach({ segment in
let scaledXValues = scaleValuesOnXAxis( segment.map({ return $0.x }) )
let scaledYValues = scaleValuesOnYAxis( segment.map({ return $0.y }) )
if series.line {
drawLine(scaledXValues, yValues: scaledYValues, seriesIndex: index)
}
if series.area {
drawArea(scaledXValues, yValues: scaledYValues, seriesIndex: index)
}
})
}
drawAxes()
if showXLabelsAndGrid && (xLabels != nil || series.count > 0) {
drawLabelsAndGridOnXAxis()
}
if showYLabelsAndGrid && (yLabels != nil || series.count > 0) {
drawLabelsAndGridOnYAxis()
}
}
fileprivate func getMinMax() -> (min: ChartPoint, max: ChartPoint) {
// Start with user-provided values
var min = (x: minX, y: minY)
var max = (x: maxX, y: maxY)
// Check in datasets
for series in self.series {
let xValues = series.data.map({ (point: ChartPoint) -> Float in
return point.x })
let yValues = series.data.map({ (point: ChartPoint) -> Float in
return point.y })
let newMinX = xValues.min()!
let newMinY = yValues.min()!
let newMaxX = xValues.max()!
let newMaxY = yValues.max()!
if min.x == nil || newMinX < min.x! { min.x = newMinX }
if min.y == nil || newMinY < min.y! { min.y = newMinY }
if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
}
// Check in labels
if xLabels != nil {
let newMinX = (xLabels!).min()!
let newMaxX = (xLabels!).max()!
if min.x == nil || newMinX < min.x! { min.x = newMinX }
if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
}
if yLabels != nil {
let newMinY = (yLabels!).min()!
let newMaxY = (yLabels!).max()!
if min.y == nil || newMinY < min.y! { min.y = newMinY }
if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
}
if min.x == nil { min.x = 0 }
if min.y == nil { min.y = 0 }
if max.x == nil { max.x = 0 }
if max.y == nil { max.y = 0 }
return (min: (x: min.x!, y: min.y!), max: (x: max.x!, max.y!))
}
fileprivate func scaleValuesOnXAxis(_ values: [Float]) -> [Float] {
let width = Float(drawingWidth)
var factor: Float
if max.x - min.x == 0 {
factor = 0
} else {
factor = width / (max.x - min.x)
}
let scaled = values.map { factor * ($0 - self.min.x) }
return scaled
}
fileprivate func scaleValuesOnYAxis(_ values: [Float]) -> [Float] {
let height = Float(drawingHeight)
var factor: Float
if max.y - min.y == 0 {
factor = 0
} else {
factor = height / (max.y - min.y)
}
let scaled = values.map { Float(self.topInset) + height - factor * ($0 - self.min.y) }
return scaled
}
fileprivate func scaleValueOnYAxis(_ value: Float) -> Float {
let height = Float(drawingHeight)
var factor: Float
if max.y - min.y == 0 {
factor = 0
} else {
factor = height / (max.y - min.y)
}
let scaled = Float(self.topInset) + height - factor * (value - min.y)
return scaled
}
fileprivate func getZeroValueOnYAxis(zeroLevel: Float) -> Float {
if min.y > zeroLevel {
return scaleValueOnYAxis(min.y)
} else {
return scaleValueOnYAxis(zeroLevel)
}
}
fileprivate func drawLine(_ xValues: [Float], yValues: [Float], seriesIndex: Int) {
// YValues are "reverted" from top to bottom, so 'above' means <= level
let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
let path = CGMutablePath()
path.move(to: CGPoint(x: CGFloat(xValues.first!), y: CGFloat(yValues.first!)))
for i in 1..<yValues.count {
let y = yValues[i]
path.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(y)))
}
let lineLayer = CAShapeLayer()
lineLayer.frame = self.bounds
lineLayer.path = path
if isAboveZeroLine {
lineLayer.strokeColor = series[seriesIndex].colors.above.cgColor
} else {
lineLayer.strokeColor = series[seriesIndex].colors.below.cgColor
}
lineLayer.fillColor = nil
lineLayer.lineWidth = lineWidth
lineLayer.lineJoin = kCALineJoinBevel
self.layer.addSublayer(lineLayer)
layerStore.append(lineLayer)
}
fileprivate func drawArea(_ xValues: [Float], yValues: [Float], seriesIndex: Int) {
// YValues are "reverted" from top to bottom, so 'above' means <= level
let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
let area = CGMutablePath()
let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: series[seriesIndex].colors.zeroLevel))
area.move(to: CGPoint(x: CGFloat(xValues[0]), y: zero))
for i in 0..<xValues.count {
area.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(yValues[i])))
}
area.addLine(to: CGPoint(x: CGFloat(xValues.last!), y: zero))
let areaLayer = CAShapeLayer()
areaLayer.frame = self.bounds
areaLayer.path = area
areaLayer.strokeColor = nil
if isAboveZeroLine {
areaLayer.fillColor = series[seriesIndex].colors.above.withAlphaComponent(areaAlphaComponent).cgColor
} else {
areaLayer.fillColor = series[seriesIndex].colors.below.withAlphaComponent(areaAlphaComponent).cgColor
}
areaLayer.lineWidth = 0
self.layer.addSublayer(areaLayer)
layerStore.append(areaLayer)
}
fileprivate func drawAxes() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(axesColor.cgColor)
context.setLineWidth(0.5)
// horizontal axis at the bottom
context.move(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
context.strokePath()
// horizontal axis at the top
context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
context.strokePath()
// horizontal axis when y = 0
if min.y < 0 && max.y > 0 {
let y = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
context.move(to: CGPoint(x: CGFloat(0), y: y))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: y))
context.strokePath()
}
// vertical axis on the left
context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
context.strokePath()
// vertical axis on the right
context.move(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
context.strokePath()
}
fileprivate func drawLabelsAndGridOnXAxis() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(gridColor.cgColor)
context.setLineWidth(0.5)
var labels: [Float]
if xLabels == nil {
// Use labels from the first series
labels = series[0].data.map({ (point: ChartPoint) -> Float in
return point.x })
} else {
labels = xLabels!
}
let scaled = scaleValuesOnXAxis(labels)
let padding: CGFloat = 5
scaled.enumerated().forEach { (i, value) in
let x = CGFloat(value)
let isLastLabel = x == drawingWidth
// Add vertical grid for each label, except axes on the left and right
if x != 0 && x != drawingWidth {
context.move(to: CGPoint(x: x, y: CGFloat(0)))
if xLabelsSkipAll {
let height: CGFloat = bounds.height - 20.0
context.addLine(to: CGPoint(x: x, y: height))
} else {
context.addLine(to: CGPoint(x: x, y: bounds.height))
}
context.strokePath()
}
if (xLabelsSkipLast && isLastLabel) || xLabelsSkipAll {
// Do not add label at the most right position
return
}
// Add label
let label = UILabel(frame: CGRect(x: x, y: drawingHeight, width: 0, height: 0))
label.font = labelFont
label.text = xLabelsFormatter(i, labels[i])
label.textColor = labelColor
// Set label size
label.sizeToFit()
// Center label vertically
label.frame.origin.y += topInset
if xLabelsOrientation == .horizontal {
// Add left padding
label.frame.origin.y -= (label.frame.height - bottomInset) / 2
label.frame.origin.x += padding
// Set label's text alignment
label.frame.size.width = (drawingWidth / CGFloat(labels.count)) - padding * 2
label.textAlignment = xLabelsTextAlignment
} else {
label.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / 2))
// Adjust vertical position according to the label's height
label.frame.origin.y += label.frame.size.height / 2
// Adjust horizontal position as the series line
label.frame.origin.x = x
if xLabelsTextAlignment == .center {
// Align horizontally in series
label.frame.origin.x += ((drawingWidth / CGFloat(labels.count)) / 2) - (label.frame.size.width / 2)
} else {
// Give some space from the vertical line
label.frame.origin.x += padding
}
}
self.addSubview(label)
}
}
fileprivate func drawLabelsAndGridOnYAxis() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(gridColor.cgColor)
context.setLineWidth(0.5)
var labels: [Float]
if yLabels == nil {
labels = [(min.y + max.y) / 2, max.y]
if yLabelsOnRightSide || min.y != 0 {
labels.insert(min.y, at: 0)
}
} else {
labels = yLabels!
}
let scaled = scaleValuesOnYAxis(labels)
let padding: CGFloat = 5
let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
scaled.enumerated().forEach { (i, value) in
let y = CGFloat(value)
// Add horizontal grid for each label, but not over axes
if y != drawingHeight + topInset && y != zero {
context.move(to: CGPoint(x: CGFloat(0), y: y))
context.addLine(to: CGPoint(x: self.bounds.width, y: y))
if labels[i] != 0 {
// Horizontal grid for 0 is not dashed
context.setLineDash(phase: CGFloat(0), lengths: [CGFloat(5)])
} else {
context.setLineDash(phase: CGFloat(0), lengths: [])
}
context.strokePath()
}
let label = UILabel(frame: CGRect(x: padding, y: y, width: 0, height: 0))
label.font = labelFont
label.text = yLabelsFormatter(i, labels[i])
label.textColor = labelColor
label.sizeToFit()
if yLabelsOnRightSide {
label.frame.origin.x = drawingWidth
label.frame.origin.x -= label.frame.width + padding
}
// Labels should be placed above the horizontal grid
label.frame.origin.y -= label.frame.height
self.addSubview(label)
}
UIGraphicsEndImageContext()
}
fileprivate func drawHighlightLineFromLeftPosition(_ left: CGFloat) {
if let shapeLayer = highlightShapeLayer {
// Use line already created
let path = CGMutablePath()
path.move(to: CGPoint(x: left, y: 0))
path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
shapeLayer.path = path
} else {
// Create the line
let path = CGMutablePath()
path.move(to: CGPoint(x: left, y: CGFloat(0)))
path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
let shapeLayer = CAShapeLayer()
shapeLayer.frame = self.bounds
shapeLayer.path = path
shapeLayer.strokeColor = highlightLineColor.cgColor
shapeLayer.fillColor = nil
shapeLayer.lineWidth = highlightLineWidth
highlightShapeLayer = shapeLayer
layer.addSublayer(shapeLayer)
layerStore.append(shapeLayer)
}
}
func handleTouchEvents(_ touches: Set<UITouch>, event: UIEvent!) {
let point = touches.first!
let left = point.location(in: self).x
let x = valueFromPointAtX(left)
if left < 0 || left > (drawingWidth as CGFloat) {
// Remove highlight line at the end of the touch event
if let shapeLayer = highlightShapeLayer {
shapeLayer.path = nil
}
delegate?.didFinishTouchingChart(self)
return
}
drawHighlightLineFromLeftPosition(left)
if delegate == nil {
return
}
var indexes: [Int?] = []
for series in self.series {
var index: Int? = nil
let xValues = series.data.map({ (point: ChartPoint) -> Float in
return point.x })
let closest = Chart.findClosestInValues(xValues, forValue: x)
if closest.lowestIndex != nil && closest.highestIndex != nil {
// Consider valid only values on the right
index = closest.lowestIndex
}
indexes.append(index)
}
delegate!.didTouchChart(self, indexes: indexes, x: x, left: left)
}
override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
leftRangePoint = touches.first!
leftRangeLocation = leftRangePoint.location(in: self).x
}
override open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
delegate?.didEndTouchingChart(self)
handleRangeTouchesEnded(touches, event: event)
}
func handleRangeTouchesEnded(_ touches: Set<UITouch>, event: UIEvent!) {
rightRangePoint = touches.first!
rightRangeLocation = rightRangePoint.location(in: self).x
// Make sure left is actually to the left
if rightRangeLocation < leftRangeLocation {
let rangePoint = leftRangePoint
let rangeLocation = leftRangeLocation
leftRangePoint = rightRangePoint
leftRangeLocation = rightRangeLocation
rightRangePoint = rangePoint
rightRangeLocation = rangeLocation
}
// Highlight the range
let layer = CAShapeLayer()
let width = rightRangeLocation - leftRangeLocation
layer.path = UIBezierPath(rect: CGRect(x: leftRangeLocation, y: topInset, width: width, height: drawingHeight)).cgPath
layer.fillColor = UIColor.red.cgColor
layer.opacity = 0.3
self.layer.addSublayer(layer)
}
override open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
}
fileprivate func valueFromPointAtX(_ x: CGFloat) -> Float {
let value = ((max.x-min.x) / Float(drawingWidth)) * Float(x) + min.x
return value
}
fileprivate func valueFromPointAtY(_ y: CGFloat) -> Float {
let value = ((max.y - min.y) / Float(drawingHeight)) * Float(y) + min.y
return -value
}
fileprivate class func findClosestInValues(_ values: [Float],
forValue value: Float
) -> (
lowestValue: Float?,
highestValue: Float?,
lowestIndex: Int?,
highestIndex: Int?
) {
var lowestValue: Float?, highestValue: Float?, lowestIndex: Int?, highestIndex: Int?
values.enumerated().forEach { (i, currentValue) in
if currentValue <= value && (lowestValue == nil || lowestValue! < currentValue) {
lowestValue = currentValue
lowestIndex = i
}
if currentValue >= value && (highestValue == nil || highestValue! > currentValue) {
highestValue = currentValue
highestIndex = i
}
}
return (
lowestValue: lowestValue,
highestValue: highestValue,
lowestIndex: lowestIndex,
highestIndex: highestIndex
)
}
fileprivate class func segmentLine(_ line: ChartLineSegment, zeroLevel: Float) -> [ChartLineSegment] {
var segments: [ChartLineSegment] = []
var segment: ChartLineSegment = []
line.enumerated().forEach { (i, point) in
segment.append(point)
if i < line.count - 1 {
let nextPoint = line[i+1]
if point.y >= zeroLevel && nextPoint.y < zeroLevel || point.y < zeroLevel && nextPoint.y >= zeroLevel {
// The segment intersects zeroLevel, close the segment with the intersection point
let closingPoint = Chart.intersectionWithLevel(point, and: nextPoint, level: zeroLevel)
segment.append(closingPoint)
segments.append(segment)
// Start a new segment
segment = [closingPoint]
}
} else {
// End of the line
segments.append(segment)
}
}
return segments
}
fileprivate class func intersectionWithLevel(_ p1: ChartPoint, and p2: ChartPoint, level: Float) -> ChartPoint {
let dy1 = level - p1.y
let dy2 = level - p2.y
return (x: (p2.x * dy1 - p1.x * dy2) / (dy1 - dy2), y: level)
}
}
Related
I want to hide my collectionView's Header cell when scrolled up. And show it again once the user scroll's down a bit. I've tried it using UICollectionViewFlowLayout but with no success.
class FilterHeaderLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
var safeAreaTop = 0.0
if #available(iOS 13.0, *) {
let window = UIApplication.shared.windows.first
safeAreaTop = window!.safeAreaInsets.top
}
layoutAttributes?.forEach({ attributes in
if attributes.representedElementKind == UICollectionView.elementKindSectionHeader{
guard let collectionView = collectionView else { return }
let width = collectionView.frame.width
let contentOfsetY = collectionView.contentOffset.y
if contentOfsetY < 0 {
//prevents header cell to drift away if user scroll all the way down
// 41 is height of a view in navigation bar. header cell needs to be below navigation bar.
attributes.frame = .init(x: 0, y: safeAreaTop+41+contentOfsetY, width: width, height: 50)
return
}
let scrollVelocity = collectionView.panGestureRecognizer.velocity(in: collectionView.superview)
if (scrollVelocity.y > 0.0) {
// show the header and make it stay at top
} else if (scrollVelocity.y < 0.0) {
// hide the header
}
attributes.frame = .init(x: 0, y: safeAreaTop+41+contentOfsetY, width: width, height: 50)
}
})
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
that's code is very old, but let's try:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let verticalOffset = proposedContentOffset.y
let targetRect = CGRect(origin: CGPoint(x: 0, y: proposedContentOffset.y), size: self.collectionView!.bounds.size)
for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
let itemOffset = layoutAttributes.frame.origin.y
if (abs(itemOffset - verticalOffset) < abs(offsetAdjustment)) {
offsetAdjustment = itemOffset - verticalOffset
}
}
return CGPoint(x: proposedContentOffset.x, y: proposedContentOffset.y + offsetAdjustment)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributesArray = super.layoutAttributesForElements(in: rect)
attributesArray?.forEach({ (attributes) in
var length: CGFloat = 0
var contentOffset: CGFloat = 0
var position: CGFloat = 0
let collectionView = self.collectionView!
if (self.scrollDirection == .horizontal) {
length = attributes.size.width
contentOffset = collectionView.contentOffset.x
position = attributes.center.x - attributes.size.width / 2
} else {
length = attributes.size.height
contentOffset = collectionView.contentOffset.y
position = attributes.center.y - attributes.size.height / 2
}
if (position >= 0 && position <= contentOffset) {
let differ: CGFloat = contentOffset - position
let alphaFactor: CGFloat = 1 - differ / length
attributes.alpha = alphaFactor
attributes.transform3D = CATransform3DMakeTranslation(0, differ, 0)
} else if (position - contentOffset > collectionView.frame.height - Layout.model.height - Layout.model.spacing
&& position - contentOffset <= collectionView.frame.height) {
let differ: CGFloat = collectionView.frame.height - position + contentOffset
let alphaFactor: CGFloat = differ / length
attributes.alpha = alphaFactor
attributes.transform3D = CATransform3DMakeTranslation(0, differ - length, 0)
attributes.zIndex = -1
} else {
attributes.alpha = 1
attributes.transform = CGAffineTransform.identity
}
})
return attributesArray
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
I am trying to crop a selected portion of NSImage which is fitted as per ProportionallyUpOrDown(AspectFill) Mode.
I am drawing a frame using mouse dragged event like this:
class CropImageView: NSImageView {
var startPoint: NSPoint!
var shapeLayer: CAShapeLayer!
var flagCheck = false
var finalPoint: NSPoint!
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
override var image: NSImage? {
set {
self.layer = CALayer()
self.layer?.contentsGravity = kCAGravityResizeAspectFill
self.layer?.contents = newValue
self.wantsLayer = true
super.image = newValue
}
get {
return super.image
}
}
override func mouseDown(with event: NSEvent) {
self.startPoint = self.convert(event.locationInWindow, from: nil)
if self.shapeLayer != nil {
self.shapeLayer.removeFromSuperlayer()
self.shapeLayer = nil
}
self.flagCheck = true
var pixelColor: NSColor = NSReadPixel(startPoint) ?? NSColor()
shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = 1.0
shapeLayer.fillColor = NSColor.clear.cgColor
if pixelColor == NSColor.black {
pixelColor = NSColor.color_white
} else {
pixelColor = NSColor.black
}
shapeLayer.strokeColor = pixelColor.cgColor
shapeLayer.lineDashPattern = [1]
self.layer?.addSublayer(shapeLayer)
var dashAnimation = CABasicAnimation()
dashAnimation = CABasicAnimation(keyPath: "lineDashPhase")
dashAnimation.duration = 0.75
dashAnimation.fromValue = 0.0
dashAnimation.toValue = 15.0
dashAnimation.repeatCount = 0.0
shapeLayer.add(dashAnimation, forKey: "linePhase")
}
override func mouseDragged(with event: NSEvent) {
let point: NSPoint = self.convert(event.locationInWindow, from: nil)
var newPoint: CGPoint = self.startPoint
let xDiff = point.x - self.startPoint.x
let yDiff = point.y - self.startPoint.y
let dist = min(abs(xDiff), abs(yDiff))
newPoint.x += xDiff > 0 ? dist : -dist
newPoint.y += yDiff > 0 ? dist : -dist
let path = CGMutablePath()
path.move(to: self.startPoint)
path.addLine(to: NSPoint(x: self.startPoint.x, y: newPoint.y))
path.addLine(to: newPoint)
path.addLine(to: NSPoint(x: newPoint.x, y: self.startPoint.y))
path.closeSubpath()
self.shapeLayer.path = path
}
override func mouseUp(with event: NSEvent) {
self.finalPoint = self.convert(event.locationInWindow, from: nil)
}
}
and selected this area as shown in picture using black dotted line:
My Cropping Code logic is this:
// resize Image Methods
extension CropProfileView {
func resizeImage(image: NSImage) -> Data {
var scalingFactor: CGFloat = 0.0
if image.size.width >= image.size.height {
scalingFactor = image.size.width/cropImgView.size.width
} else {
scalingFactor = image.size.height/cropImgView.size.height
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingFactor
let xPos = ((image.size.width/2) - (cropImgView.bounds.midX - self.cropImgView.startPoint.x) * scalingFactor)
let yPos = ((image.size.height/2) - (cropImgView.bounds.midY - (cropImgView.size.height - self.cropImgView.startPoint.y)) * scalingFactor)
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
let imageRef = image.cgImage(forProposedRect: &croppedRect, context: nil, hints: nil)
guard let croppedImage = imageRef?.cropping(to: croppedRect) else {return Data()}
let imageWithNewSize = NSImage(cgImage: croppedImage, size: NSSize(width: width, height: height))
guard let data = imageWithNewSize.tiffRepresentation,
let rep = NSBitmapImageRep(data: data),
let imgData = rep.representation(using: .png, properties: [.compressionFactor: NSNumber(floatLiteral: 0.25)]) else {
return imageWithNewSize.tiffRepresentation ?? Data()
}
return imgData
}
}
With this cropping logic i am getting this output:
I think as image is AspectFill thats why its not getting cropped in perfect size as per selected frame. Here if you look at output: xpositon & width & heights are not perfect. Or probably i am not calculating these co-ordinates properly. Let me know the faults probably i am calculating someting wrong.
Note: the CropImageView class in the question is a subclass of NSImageView but the view is layer-hosting and the image is drawn by the layer, not by NSImageView. imageScaling is not used.
When deciding which scaling factor to use you have to take the size of the image view into account. If the image size is width:120, height:100 and the image view size is width:120, height 80 then image.size.width >= image.size.height is true and image.size.width/cropImgView.size.width is 1 but the image is scaled because image.size.height/cropImgView.size.height is 1.25. Calculate the horizontal and vertical scaling factors and use the largest.
See How to crop a UIImageView to a new UIImage in 'aspect fill' mode?
Here's the calculation of croppedRect assuming cropImgView.size returns self.layer!.bounds.size.
var scalingWidthFactor: CGFloat = image.size.width/cropImgView.size.width
var scalingHeightFactor: CGFloat = image.size.height/cropImgView.size.height
var xOffset: CGFloat = 0
var yOffset: CGFloat = 0
switch cropImgView.layer?.contentsGravity {
case CALayerContentsGravity.resize: break
case CALayerContentsGravity.resizeAspect:
if scalingWidthFactor > scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
case CALayerContentsGravity.resizeAspectFill:
if scalingWidthFactor < scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
default:
print("contentsGravity \(String(describing: cropImgView.layer?.contentsGravity)) is not supported")
return nil
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingWidthFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingHeightFactor
let xPos = (self.cropImgView.startPoint.x - xOffset) * scalingWidthFactor
let yPos = (cropImgView.size.height - self.cropImgView.startPoint.y - yOffset) * scalingHeightFactor
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
Bugfix: cropImgView.finalPoint should be the corner of the selection, not the location of mouseUp. In CropImageView set self.finalPoint = newPoint in mouseDragged instead of mouseUp.
'''
import UIKit
class CanvasView: UIView {
var circleViewTag = 1000
var coordinatePoints: [String] = ["243,103","534,86","243,286","426,286"] {
didSet {
self.updateCoordinateArray()
self.drawPoints()
}
}
fileprivate var coordArray: [CGPoint] = []
var shape = CAShapeLayer()
var path = UIBezierPath()
/*// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
}*/
private func drawPoints() -> Void {
CommonMethods.printLog("\(coordinatePoints)")
self.layer.addSublayer(shape)
shape.opacity = 0.5
shape.lineWidth = 2
shape.lineJoin = CAShapeLayerLineJoin.miter
shape.strokeColor = UIColor.white.cgColor
shape.fillColor = UIColor(red: 1.0, green: 0.2, blue: 0.2, alpha: 0.2).cgColor
if let firstCoord = self.coordArray.first {
path.move(to: firstCoord)
}
for (index, cgPoint) in self.coordArray.enumerated() {
self.drawCircularPoint(points: cgPoint)
if index == 0 {
continue
}
path.addLine(to: cgPoint)
}
path.close()
shape.path = path.cgPath
//self.drawLineBetweenPoints()
}
private func drawCircularPoint(points: CGPoint) -> Void {
let circleView = UIView.init(frame: .zero)
circleViewTag = circleViewTag + 1
circleView.tag = circleViewTag
circleView.frame.size = CGSize.init(width: 30.0, height: 30.0)
circleView.layer.cornerRadius = 15.0
circleView.center = points
circleView.backgroundColor = .random()
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.draggedView(_:)))
panGesture.view?.tag = circleView.tag
circleView.isUserInteractionEnabled = true
circleView.addGestureRecognizer(panGesture)
self.addSubview(circleView)
}
#objc func draggedView(_ sender:UIPanGestureRecognizer){
guard let getTag = sender.view?.tag else { return }
if let viewToDrag = self.viewWithTag(getTag) as? UIView {
var currentPoint: CGPoint = .zero
if path.contains(viewToDrag.center) {
print("YES")
currentPoint = path.currentPoint
}
let translation = sender.translation(in: self)
viewToDrag.center = CGPoint(x: viewToDrag.center.x + translation.x, y: viewToDrag.center.y + translation.y)
sender.setTranslation(CGPoint.zero, in: self)
if sender.state == .began && currentPoint != .zero {
let coordinateIndex = self.coordArray.firstIndex { (cgpoint) -> Bool in
if currentPoint == cgpoint {
return true
}
return false
}
if coordinateIndex != nil {
self.coordArray[coordinateIndex!] = viewToDrag.center
self.shape.removeFromSuperlayer()
self.path.removeAllPoints()
self.setNeedsDisplay()
self.layer.addSublayer(self.shape)
self.shape.opacity = 0.5
self.shape.lineWidth = 2
self.shape.lineJoin = CAShapeLayerLineJoin.miter
self.shape.strokeColor = UIColor.white.cgColor
self.shape.fillColor = UIColor(red: 1.0, green: 0.2, blue: 0.2, alpha: 0.2).cgColor
if let firstCoord = self.coordArray.first {
path.move(to: firstCoord)
}
for (index, cgPoint) in self.coordArray.enumerated() {
//self.drawCircularPoint(points: cgPoint)
if index == 0 {
continue
}
path.addLine(to: cgPoint)
}
path.close()
shape.path = path.cgPath
}
}
}
//self.bringSubviewToFront(viewDrag)
}
private func updateCoordinateArray() -> Void {
for singleCoordinate in self.coordinatePoints {
if singleCoordinate.contains(",") == true {
let splitCoordinate = singleCoordinate.split(separator: ",")
if splitCoordinate.count == 2 {
let xPos = CGFloat(Float(splitCoordinate[0]) ?? 0.0)
let yPos = CGFloat(Float(splitCoordinate[1]) ?? 0.0)
let cgPoint = CGPoint(x: xPos, y: yPos)
self.coordArray.append(cgPoint)
}
}
}
var penultimateIndex: Int?
if let penultimateCoordinate = self.coordArray.penultimate() {
penultimateIndex = self.coordArray.firstIndex { (cgpoint) -> Bool in
if penultimateCoordinate == cgpoint {
return true
}
return false
}
}
var lastIndex: Int?
if let lastCoordinate = self.coordArray.last {
lastIndex = self.coordArray.firstIndex { (cgpoint) -> Bool in
if lastCoordinate == cgpoint {
return true
}
return false
}
}
if penultimateIndex != nil && lastIndex != nil {
self.coordArray.swapAt(penultimateIndex!, lastIndex!)
}
}
'''
I am creating a polygon using UIBezierpath and CAShapelayer. Added pan gesture on all 4 points that is UIView. When I drag the point A,B,C,D the expected behaviour is that bezierpath and CAShapelayer gets updated with the updated points. And when user drag the inner part of the shape all the path gets updated. But I am unable to update the path and shape. Can anyone help me with this?
yon set name for layer
var shape = CAShapeLayer()
shape.name = "name1"
and then, instead of updating, you can delete it by first searching by name, and then add
I'm trying to do the below progress
Using MKMagneticProgress, I was able to apply the bottom space, but now I'm stuck on how to do the spaces in the circle, for example in this image we have the progress of 7 items out of 10.
What I want is to update the MKMagneticProgress code to add the spaces.
the code taken from MKMagneticProgress with my update (I added the total)
import UIKit
// MARK: - Line Cap Enum
public enum LineCap : Int{
case round, butt, square
public func style() -> String {
switch self {
case .round:
return CAShapeLayerLineCap.round.rawValue
case .butt:
return CAShapeLayerLineCap.butt.rawValue
case .square:
return CAShapeLayerLineCap.square.rawValue
}
}
}
// MARK: - Orientation Enum
public enum Orientation: Int {
case left, top, right, bottom
}
#IBDesignable
open class CircleProgress: UIView {
// MARK: - Variables
private let titleLabelWidth:CGFloat = 100
private let percentLabel = UILabel(frame: .zero)
#IBInspectable open var titleLabel = UILabel(frame: .zero)
/// Stroke background color
#IBInspectable open var clockwise: Bool = true {
didSet {
layoutSubviews()
}
}
/// Stroke background color
#IBInspectable open var backgroundShapeColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
updateShapes()
}
}
/// Progress stroke color
#IBInspectable open var progressShapeColor: UIColor = .blue {
didSet {
updateShapes()
}
}
/// Line width
#IBInspectable open var lineWidth: CGFloat = 8.0 {
didSet {
updateShapes()
}
}
/// Space value
#IBInspectable open var spaceDegree: CGFloat = 45.0 {
didSet {
// if spaceDegree < 45.0{
// spaceDegree = 45.0
// }
//
// if spaceDegree > 135.0{
// spaceDegree = 135.0
// }
layoutSubviews()
updateShapes()
}
}
/// The progress shapes line width will be the `line width` minus the `inset`.
#IBInspectable open var inset: CGFloat = 0.0 {
didSet {
updateShapes()
}
}
// The progress percentage label(center label) format
#IBInspectable open var percentLabelFormat: String = "%.f %%" {
didSet {
percentLabel.text = String(format: percentLabelFormat, progress * 100)
}
}
#IBInspectable open var percentColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
percentLabel.textColor = percentColor
}
}
/// progress text (progress bottom label)
#IBInspectable open var title: String = "" {
didSet {
titleLabel.text = title
}
}
#IBInspectable open var titleColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
titleLabel.textColor = titleColor
}
}
// progress text (progress bottom label)
#IBInspectable open var font: UIFont = .systemFont(ofSize: 13) {
didSet {
titleLabel.font = font
percentLabel.font = font
}
}
// progress Orientation
open var orientation: Orientation = .bottom {
didSet {
updateShapes()
}
}
/// Progress shapes line cap.
open var lineCap: LineCap = .round {
didSet {
updateShapes()
}
}
/// Returns the total Items
private var _total: CGFloat = 1.0
#IBInspectable open var total: CGFloat {
set {
self._total = newValue
self.setProgress(progress: self.progress)
}
get {
return self._total
}
}
/// Returns the current progress.
#IBInspectable open private(set) var progress: CGFloat {
set {
progressShape?.strokeEnd = newValue
}
get {
return progressShape.strokeEnd
}
}
/// Duration for a complete animation from 0.0 to 1.0.
open var completeDuration: Double = 2.0
private var backgroundShape: CAShapeLayer!
private var progressShape: CAShapeLayer!
private var progressAnimation: CABasicAnimation!
// MARK: - Init
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
private func setup() {
backgroundShape = CAShapeLayer()
backgroundShape.fillColor = nil
backgroundShape.strokeColor = backgroundShapeColor.cgColor
layer.addSublayer(backgroundShape)
progressShape = CAShapeLayer()
progressShape.fillColor = nil
progressShape.strokeStart = 0.0
progressShape.strokeEnd = 0.1
layer.addSublayer(progressShape)
progressAnimation = CABasicAnimation(keyPath: "strokeEnd")
percentLabel.frame = self.bounds
percentLabel.textAlignment = .center
// percentLabel.textColor = self.progressShapeColor
self.addSubview(percentLabel)
percentLabel.text = String(format: "%.1f%%", progress * 100)
titleLabel.frame = CGRect(x: (self.bounds.size.width-titleLabelWidth)/2, y: self.bounds.size.height-21, width: titleLabelWidth, height: 21)
titleLabel.textAlignment = .center
// titleLabel.textColor = self.progressShapeColor
titleLabel.text = title
titleLabel.contentScaleFactor = 0.3
// textLabel.adjustFontSizeToFit()
titleLabel.numberOfLines = 2
//textLabel.adjustFontSizeToFit()
titleLabel.adjustsFontSizeToFitWidth = true
self.addSubview(titleLabel)
}
// MARK: - Progress Animation
public func setProgress(progress: CGFloat, animated: Bool = true) {
let actualProgress = progress/self._total
if actualProgress > 1.0 {
return
}
var start = progressShape.strokeEnd
if let presentationLayer = progressShape.presentation(){
if let count = progressShape.animationKeys()?.count, count > 0 {
start = presentationLayer.strokeEnd
}
}
let duration = abs(Double(progress - start)) * completeDuration
percentLabel.text = String(format: percentLabelFormat, actualProgress * 100)
progressShape.strokeEnd = actualProgress
if animated {
progressAnimation.fromValue = start
progressAnimation.toValue = actualProgress
progressAnimation.duration = duration
progressShape.add(progressAnimation, forKey: progressAnimation.keyPath)
}
}
// MARK: - Layout
open override func layoutSubviews() {
super.layoutSubviews()
backgroundShape.frame = bounds
progressShape.frame = bounds
let rect = rectForShape()
backgroundShape.path = pathForShape(rect: rect).cgPath
progressShape.path = pathForShape(rect: rect).cgPath
self.titleLabel.frame = CGRect(x: (self.bounds.size.width - titleLabelWidth)/2, y: self.bounds.size.height-50, width: titleLabelWidth, height: 42)
updateShapes()
percentLabel.frame = self.bounds
}
private func updateShapes() {
backgroundShape?.lineWidth = lineWidth
backgroundShape?.strokeColor = backgroundShapeColor.cgColor
backgroundShape?.lineCap = CAShapeLayerLineCap(rawValue: lineCap.style())
progressShape?.strokeColor = progressShapeColor.cgColor
progressShape?.lineWidth = lineWidth - inset
progressShape?.lineCap = CAShapeLayerLineCap(rawValue: lineCap.style())
switch orientation {
case .left:
titleLabel.isHidden = true
self.progressShape.transform = CATransform3DMakeRotation( CGFloat.pi / 2, 0, 0, 1.0)
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi / 2, 0, 0, 1.0)
case .right:
titleLabel.isHidden = true
self.progressShape.transform = CATransform3DMakeRotation( CGFloat.pi * 1.5, 0, 0, 1.0)
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi * 1.5, 0, 0, 1.0)
case .bottom:
titleLabel.isHidden = false
UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: [] , animations: { [weak self] in
if let temp = self{
temp.titleLabel.frame = CGRect(x: (temp.bounds.size.width - temp.titleLabelWidth)/2, y: temp.bounds.size.height-50, width: temp.titleLabelWidth, height: 42)
}
}, completion: nil)
self.progressShape.transform = CATransform3DMakeRotation( CGFloat.pi * 2, 0, 0, 1.0)
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi * 2, 0, 0, 1.0)
case .top:
titleLabel.isHidden = false
UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: [] , animations: { [weak self] in
if let temp = self{
temp.titleLabel.frame = CGRect(x: (temp.bounds.size.width - temp.titleLabelWidth)/2, y: 0, width: temp.titleLabelWidth, height: 42)
}
}, completion: nil)
self.progressShape.transform = CATransform3DMakeRotation( CGFloat.pi, 0, 0, 1.0)
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi, 0, 0, 1.0)
}
}
// MARK: - Helper
private func rectForShape() -> CGRect {
return bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)
}
private func pathForShape(rect: CGRect) -> UIBezierPath {
let startAngle:CGFloat!
let endAngle:CGFloat!
if clockwise{
startAngle = CGFloat(spaceDegree * .pi / 180.0) + (0.5 * .pi)
endAngle = CGFloat((360.0 - spaceDegree) * (.pi / 180.0)) + (0.5 * .pi)
}else{
startAngle = CGFloat((360.0 - spaceDegree) * (.pi / 180.0)) + (0.5 * .pi)
endAngle = CGFloat(spaceDegree * .pi / 180.0) + (0.5 * .pi)
}
let path = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY), radius: rect.size.width / 2.0, startAngle: startAngle, endAngle: endAngle
, clockwise: clockwise)
return path
}
}
My output:
Thanks.
I was able to figure out how to update the code to add multiple arcs here is the updated code
import UIKit
// MARK: - Line Cap Enum
public enum LineCap : Int{
case round, butt, square
public func style() -> String {
switch self {
case .round:
return CAShapeLayerLineCap.round.rawValue
case .butt:
return CAShapeLayerLineCap.butt.rawValue
case .square:
return CAShapeLayerLineCap.square.rawValue
}
}
}
// MARK: - Orientation Enum
public enum Orientation: Int {
case left, top, right, bottom
}
#IBDesignable
open class CircleProgress: UIView {
// MARK: - Variables
private let titleLabelWidth:CGFloat = 100
private let percentLabel = UILabel(frame: .zero)
#IBInspectable open var titleLabel = UILabel(frame: .zero)
/// Stroke background color
#IBInspectable open var clockwise: Bool = true {
didSet {
layoutSubviews()
}
}
/// Stroke background color
#IBInspectable open var backgroundShapeColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
updateShapes()
}
}
/// Progress stroke color
#IBInspectable open var progressShapeColor: UIColor = .blue {
didSet {
updateShapes()
}
}
/// Line width
#IBInspectable open var lineWidth: CGFloat = 5.0 {
didSet {
updateShapes()
}
}
/// Space value
#IBInspectable open var spaceDegree: CGFloat = 45.0 {
didSet {
// if spaceDegree < 45.0{
// spaceDegree = 45.0
// }
//
// if spaceDegree > 135.0{
// spaceDegree = 135.0
// }
layoutSubviews()
updateShapes()
}
}
/// The progress shapes line width will be the `line width` minus the `inset`.
#IBInspectable open var inset: CGFloat = 0.0 {
didSet {
updateShapes()
}
}
// The progress percentage label(center label) format
#IBInspectable open var percentLabelFormat: String = "%.f %%" {
didSet {
percentLabel.text = String(format: percentLabelFormat, progress * 100)
}
}
#IBInspectable open var percentColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
percentLabel.textColor = percentColor
}
}
/// progress text (progress bottom label)
#IBInspectable open var title: String = "" {
didSet {
titleLabel.text = title
}
}
#IBInspectable open var titleColor: UIColor = UIColor(white: 0.9, alpha: 0.5) {
didSet {
titleLabel.textColor = titleColor
}
}
// progress text (progress bottom label)
#IBInspectable open var font: UIFont = .systemFont(ofSize: 13) {
didSet {
titleLabel.font = font
percentLabel.font = font
}
}
// progress Orientation
open var orientation: Orientation = .bottom {
didSet {
updateShapes()
}
}
/// Progress shapes line cap.
open var lineCap: LineCap = .round {
didSet {
updateShapes()
}
}
/// Returns the total Items
private var _total: Int = 1
#IBInspectable open var total: Int {
set {
self._total = newValue
self.addProgressShapes()
}
get {
return self._total
}
}
/// Returns the current progress.
private var _progress: CGFloat = 0.0
#IBInspectable open private(set) var progress: CGFloat {
set {
self._progress = newValue
}
get {
return self._progress
}
}
/// Duration for a complete animation from 0.0 to 1.0.
open var completeDuration: Double = 1.0
private var backgroundShape: CAShapeLayer!
private var progressShapes: [CAShapeLayer]!
private var progressAnimation: CABasicAnimation!
// MARK: - Init
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
private func setup() {
backgroundShape = CAShapeLayer()
backgroundShape.fillColor = nil
backgroundShape.strokeColor = backgroundShapeColor.cgColor
layer.addSublayer(backgroundShape)
progressAnimation = CABasicAnimation(keyPath: "strokeEnd")
percentLabel.frame = self.bounds
percentLabel.textAlignment = .center
// percentLabel.textColor = self.progressShapeColor
self.addSubview(percentLabel)
percentLabel.text = String(format: "%.1f%%", progress * 100)
titleLabel.frame = CGRect(x: (self.bounds.size.width-titleLabelWidth)/2, y: self.bounds.size.height-21, width: titleLabelWidth, height: 21)
titleLabel.textAlignment = .center
// titleLabel.textColor = self.progressShapeColor
titleLabel.text = title
titleLabel.contentScaleFactor = 0.3
// textLabel.adjustFontSizeToFit()
titleLabel.numberOfLines = 2
//textLabel.adjustFontSizeToFit()
titleLabel.adjustsFontSizeToFitWidth = true
self.addSubview(titleLabel)
}
// MARK: - Progress Animation
private func addProgressShapes(){
self.progressShapes = []
let progressSize = 1.0
var size:CGFloat = CGFloat(progressSize / Double(self.total))
let padingPercent = 0.2
let pading: Double = padingPercent * Double(self.total)/10 * Double(progressSize / Double(self.total - 1))
size = size - CGFloat(pading)
print("")
print("size: \(size) | pading: \(pading)")
print("------------------------")
var start: CGFloat = 0.0
var end: CGFloat = size
print("start: \(start) | end: \(end) ")
print("------------------------")
let progressShape: CAShapeLayer!
progressShape = CAShapeLayer()
progressShape.fillColor = nil
progressShape.strokeStart = start
progressShape.strokeEnd = end
layer.addSublayer(progressShape)
self.progressShapes.append(progressShape)
for _ in 1..<self._total {
start = CGFloat(end + CGFloat(pading))
end = CGFloat(Double(start + size))
print("------------------------")
print("start: \(start) | end: \(end) ")
let progressShape: CAShapeLayer!
progressShape = CAShapeLayer()
progressShape.fillColor = nil
progressShape.strokeStart = start
progressShape.strokeEnd = end
layer.addSublayer(progressShape)
self.progressShapes.append(progressShape)
}
}
private func setProgressShapeFrame(){
guard self.progressShapes != nil else { return }
for shape in self.progressShapes {
shape.frame = bounds
let rect = rectForShape()
shape.path = pathForShape(rect: rect).cgPath
}
}
private func updateShapesWidth(){
guard self.progressShapes != nil else { return }
for i in 0..<self._total {
let shape = self.progressShapes[i]
let color = CGFloat(i) >= self.progress ? UIColor.white.withAlphaComponent(0.1).cgColor:progressShapeColor.cgColor
shape.strokeColor = color
shape.lineWidth = lineWidth - inset
shape.lineCap = CAShapeLayerLineCap(rawValue: lineCap.style())
}
}
private func transformShapes(_ transform: CATransform3D){
guard self.progressShapes != nil else { return }
for shape in self.progressShapes {
shape.transform = transform
}
}
public func setProgress(progress: CGFloat, animated: Bool = true) {
self._progress = progress
// let actualProgress = progress/self._total
// if actualProgress > 1.0 {
// return
// }
//
// var start = progressShape.strokeEnd
// if let presentationLayer = progressShape.presentation(){
// if let count = progressShape.animationKeys()?.count, count > 0 {
// start = presentationLayer.strokeEnd
// }
// }
//
// let duration = abs(Double(progress - start)) * completeDuration
// percentLabel.text = String(format: percentLabelFormat, actualProgress * 100)
// progressShape.strokeEnd = actualProgress
//
// if animated {
// progressAnimation.fromValue = start
// progressAnimation.toValue = actualProgress
// progressAnimation.duration = duration
// progressShape.add(progressAnimation, forKey: progressAnimation.keyPath)
// }
}
// MARK: - Layout
open override func layoutSubviews() {
super.layoutSubviews()
backgroundShape.frame = bounds
let rect = rectForShape()
backgroundShape.path = pathForShape(rect: rect).cgPath
setProgressShapeFrame()
self.titleLabel.frame = CGRect(x: (self.bounds.size.width - titleLabelWidth)/2, y: self.bounds.size.height-50, width: titleLabelWidth, height: 42)
updateShapes()
percentLabel.frame = self.bounds
}
private func updateShapes() {
backgroundShape?.lineWidth = lineWidth
backgroundShape?.strokeColor = backgroundShapeColor.cgColor
backgroundShape?.lineCap = CAShapeLayerLineCap(rawValue: lineCap.style())
self.updateShapesWidth()
switch orientation {
case .left:
titleLabel.isHidden = true
self.transformShapes(CATransform3DMakeRotation( CGFloat.pi / 2, 0, 0, 1.0))
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi / 2, 0, 0, 1.0)
case .right:
titleLabel.isHidden = true
self.transformShapes(CATransform3DMakeRotation( CGFloat.pi * 1.5, 0, 0, 1.0))
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi * 1.5, 0, 0, 1.0)
case .bottom:
titleLabel.isHidden = false
UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: [] , animations: { [weak self] in
if let temp = self{
temp.titleLabel.frame = CGRect(x: (temp.bounds.size.width - temp.titleLabelWidth)/2, y: temp.bounds.size.height-50, width: temp.titleLabelWidth, height: 42)
}
}, completion: nil)
self.transformShapes(CATransform3DMakeRotation( CGFloat.pi * 2, 0, 0, 1.0))
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi * 2, 0, 0, 1.0)
case .top:
titleLabel.isHidden = false
UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: [] , animations: { [weak self] in
if let temp = self{
temp.titleLabel.frame = CGRect(x: (temp.bounds.size.width - temp.titleLabelWidth)/2, y: 0, width: temp.titleLabelWidth, height: 42)
}
}, completion: nil)
self.transformShapes(CATransform3DMakeRotation( CGFloat.pi, 0, 0, 1.0))
self.backgroundShape.transform = CATransform3DMakeRotation(CGFloat.pi, 0, 0, 1.0)
}
}
// MARK: - Helper
private func rectForShape() -> CGRect {
return bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)
}
private func pathForShape(rect: CGRect) -> UIBezierPath {
let startAngle:CGFloat!
let endAngle:CGFloat!
if clockwise{
startAngle = CGFloat(spaceDegree * .pi / 180.0) + (0.5 * .pi)
endAngle = CGFloat((360.0 - spaceDegree) * (.pi / 180.0)) + (0.5 * .pi)
}else{
startAngle = CGFloat((360.0 - spaceDegree) * (.pi / 180.0)) + (0.5 * .pi)
endAngle = CGFloat(spaceDegree * .pi / 180.0) + (0.5 * .pi)
}
let path = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY), radius: rect.size.width / 2.0, startAngle: startAngle, endAngle: endAngle
, clockwise: clockwise)
return path
}
}
UPDATE: This does not happen if I set only the board as the main view. Why would this happen when it is a subview?
I have a view that has a game board that you draw over to select characters. To do this I put a UIImageView over the game board and draw the line in touches moved using a helper function. It works, however, while drawing the line, everything that came before it slowly fades and also moves upwards.
It looks like this:
This is what the draw function looks like and it is called in touches moved:
//Helper function that draws a line which is used while the user is selecting letters
func drawLine(fromPoint: CGPoint, toPoint: CGPoint){
UIGraphicsBeginImageContext(drawingView.frame.size)
//get a context
guard let context: CGContext = UIGraphicsGetCurrentContext() else {
print("failed to get image context to draw line")
return
}
self.drawingView.image?.draw(in: CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height))
//set the line
context.move(to: fromPoint)
context.addLine(to: toPoint)
//customize line
context.setLineCap(CGLineCap.round)
context.setLineWidth(4)
context.setStrokeColor(UIColor.blue.cgColor)
context.setBlendMode(CGBlendMode.normal)
//draw the line
context.strokePath()
drawingView.image = UIGraphicsGetImageFromCurrentImageContext()
//close
UIGraphicsEndImageContext()
//print("Drew Line")
}
Heres all the code for the view:
/**
GameControllerDelegate is used to communicate when the user has chosen a word
*/
protocol GameControllerDelegate: AnyObject {
func wordPicked(letters: [String], moves: [(row: Int, column: Int)])
}
/**
GameViewDelegate is used to let the gameview know a user has picked a word using the board which is a subview
*/
protocol GameViewDelegate: AnyObject {
func wordPickedInBoard(letters: [String], moves: [(row: Int, column: Int)])
}
/**
Game view class holds all the subviews of the Game view as well as provides
getters and setters for each important data
*/
class GameView: UIView , GameViewDelegate{
//the board part
let boardView: BoardView = {
let boardView = BoardView()
boardView.backgroundColor = UIColor.white
boardView.translatesAutoresizingMaskIntoConstraints = true
return boardView
}()
//the score part
let scoreView: ScoreView = {
let scoreView = ScoreView()
scoreView.backgroundColor = UIColor.white
scoreView.translatesAutoresizingMaskIntoConstraints = true
return scoreView
}()
//two other vairables to assist in sizing the subviews
var minusTop: CGFloat = 0
var minusBottom: CGFloat = 0
//the board for the game
var board: [[String]] {
set
{
boardView.boardStrings = newValue
boardView.generateLabels()
setNeedsDisplay()
}
get { return boardView.boardStrings }
}
//the score
var score: Int {
set
{
scoreView.gameScore.text = " Score: " + String(newValue)
}
get
{
let indexStartOfNumber = scoreView.gameScore.text!.index((scoreView.gameScore.text!.startIndex), offsetBy: 9)
let numString = scoreView.gameScore.text?[indexStartOfNumber...]
return Int(String(describing: numString))!
}
}
//delegate for alerting the controller that a word was picked
var delegate: GameControllerDelegate? = nil
init(frame: CGRect, minusTop: CGFloat, minusBottom: CGFloat){
super.init(frame: frame)
boardView.delegate = self
self.minusTop = minusTop
self.minusBottom = minusBottom
addSubview(boardView)
addSubview(scoreView)
}
override init(frame: CGRect){
super.init(frame: frame)
boardView.delegate = self
addSubview(boardView)
addSubview(scoreView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("It's Apple. What did you expect?")
}
//manually layout the alarmpreview
override func layoutSubviews() {
var cursor: CGPoint = .zero
let scoreViewHeight = bounds.height/10
let boardHeight = bounds.height - minusTop - minusBottom - scoreViewHeight
cursor.y += minusTop
boardView.frame = CGRect(x: cursor.x, y: cursor.y, width: bounds.width, height: boardHeight)
cursor.y += boardHeight
scoreView.frame = CGRect(x: cursor.x, y: cursor.y, width: bounds.width, height: scoreViewHeight)
}
//function to catch when a word is picked in the board
func wordPickedInBoard(letters: [String], moves: [(row: Int, column: Int)]) {
//forward the info via the delegate
delegate?.wordPicked(letters: letters, moves: moves)
}
}
/**
Score view shows the users current score
*/
class ScoreView: UIView {
/*var scoreTitle: UILabel = {
let scoreTitle = UILabel()
scoreTitle.backgroundColor = UIColor.white
scoreTitle.text = "Score: "
return scoreTitle
}()*/
var gameScore: UILabel = {
let gameScore = UILabel()
gameScore.backgroundColor = UIColor.white
gameScore.text = " Score: 0"
return gameScore
}()
//init
override init(frame: CGRect){
super.init(frame: frame)
addSubview(gameScore)
//addSubview(scoreTitle)
}
required init?(coder aDecoder: NSCoder) {
fatalError("It's Apple. What did you expect?")
}
override func layoutSubviews() {
let cursor: CGPoint = .zero
gameScore.frame = CGRect(x: cursor.x, y: cursor.y, width: bounds.width, height: bounds.height)
}
}
/**
VoardView holds all the cells of the board and communicates when a user draws on it
*/
class BoardView: UIView {
var drawingView: UIImageView = {
let drawingView = UIImageView()
drawingView.backgroundColor = UIColor.white.withAlphaComponent(0.0)
return drawingView
}()
//delegate to communicate with the superview
var delegate: GameViewDelegate? = nil
//variables to aid in tracking the user moves
var lastPoint = CGPoint.zero
var looped = false
var boardStrings:[[String]] = []
var board:[[UILabel]] = Array(repeating: Array(repeating: UILabel(), count: 9), count: 12)
var curMoves: [(row: Int, column: Int)] = []
var highlightedArea: [(row: Int, column: Int)] = []
//centralized colors
let unHighlightedColor = UIColor.lightGray
let highlightedColor = UIColor.yellow
//Initialize this view
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.white
initBoard()
//self.boardStrings = generateBoard()
//generateLabels(boardStrings: self.boardStrings)
for i in 0...11 {
for j in 0...8 {
addSubview(board[i][j])
}
}
addSubview(drawingView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("It's Apple. What did you expect?")
}
//override the draw function, we draw the grid as well as the letters here
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context: CGContext = UIGraphicsGetCurrentContext() else {
print("failed to attain draw context")
return
}
//8 verticle lines
for i in 1 ... 9 {
let fromPoint = CGPoint(x: bounds.width * CGFloat(Double(i) * 1.0/9.0), y: 0)
let toPoint = CGPoint(x: bounds.width * CGFloat(Double(i) * 1.0/9.0), y: bounds.height)
context.move(to: fromPoint)
context.addLine(to: toPoint)
context.strokePath()
}
//draw 11 horizontal lines
for i in 1 ... 12 {
let fromPoint = CGPoint(x: 0, y: bounds.height * CGFloat(Double(i) * 1.0/12.0))
let toPoint = CGPoint(x: bounds.width, y: bounds.height * CGFloat(Double(i) * 1.0/12.0))
context.move(to: fromPoint)
context.addLine(to: toPoint)
context.strokePath()
}
}
//here we manually layout all the subviews that go into the preview
override func layoutSubviews() {
var cursor: CGPoint = .zero
let width = bounds.width/9
let height = bounds.height/12
for i in 0 ... 11 {
for j in 0 ... 8 {
board[i][j].frame = CGRect(x: cursor.x, y: cursor.y, width: width, height: height)
cursor.x += bounds.width * 1.0/9.0
}
cursor.x = 0
cursor.y += bounds.height * 1.0/12.0
}
drawingView.frame = self.bounds
}
//handle the beginning of a user move
override func touchesBegan(_ touches: Set<UITouch>, with: UIEvent?) {
looped = false
if let touch = touches.first as? UITouch {
lastPoint = touch.location(in: self)
//find what row we are in
let buttonHeight = bounds.height/12
let buttonWidth = bounds.width/9
let row: Int = Int(lastPoint.y/buttonHeight)
let column: Int = Int(lastPoint.x/buttonWidth)
curMoves.append((row: row, column: column))
highlightLabel(row: row, column: column)
setNeedsDisplay()
}
}
//handle when the user is moving. Calculate coordintes to drive the move the player is making
override func touchesMoved(_ touches: Set<UITouch>, with: UIEvent?) {
if !looped {
if let touch = touches.first as? UITouch {
let currentPoint = touch.location(in: self)
drawLine(fromPoint: lastPoint, toPoint: currentPoint)
//find what row we are in
let buttonHeight = bounds.height/12
let buttonWidth = bounds.width/9
let row: Int = Int(lastPoint.y/buttonHeight)
let column: Int = Int(lastPoint.x/buttonWidth)
if curMoves[curMoves.count - 1] != (row: row, column: column) {
//if the user is backstepping
if curMoves.count > 1 {
if curMoves[curMoves.count - 2] == (row: row, column: column) {
let pos = curMoves.remove(at: curMoves.count - 1)
unHeighlightLabel(row: pos.row, column: pos.column)
}
//if the user tried to make a loop
else if curMoves.contains(where: {$0 == (row: row, column: column)}){
for pos in curMoves {
unHeighlightLabel(row: pos.row, column: pos.column)
}
print("User tried to make a loop")
looped = true
}
else {
curMoves.append((row: row, column: column))
if board[row][column].backgroundColor != UIColor.green{
highlightLabel(row: row, column: column)
}
}
}
//if the user tried to make a loop
/*else if curMoves.contains(where: {$0 == (row: row, column: column)}){
for pos in curMoves {
unHeighlightLabel(row: pos.row, column: pos.column)
}
print("User tried to make a loop")
looped = true
}*/
else {
curMoves.append((row: row, column: column))
if board[row][column].backgroundColor != UIColor.green{
highlightLabel(row: row, column: column)
}
}
}
lastPoint = currentPoint
}
}
setNeedsDisplay()
}
//handle the end of a player move
override func touchesEnded(_ touches: Set<UITouch>, with: UIEvent?) {
if !looped {
//derive word
var word: [String] = []
for move in curMoves {
//if it contains blank put some garbage in there
if !boardStrings[move.row][move.column].contains("Blank") {
word.append(board[move.row][move.column].text!)
}
else {
word.append("###")
}
}
//let superview know
delegate?.wordPickedInBoard(letters: word, moves: curMoves)
}
//reset
for move in curMoves {
if board[move.row][move.column].backgroundColor != UIColor.green{
unHeighlightLabel(row: move.row, column: move.column)
}
}
curMoves = []
looped = false
drawingView.image = nil
setNeedsDisplay()
}
//Helper functions that changes the background color of the specified label to a centralized color
func highlightLabel(row: Int, column: Int){
board[row][column].backgroundColor = highlightedColor
}
func unHeighlightLabel(row: Int, column: Int) {
board[row][column].backgroundColor = unHighlightedColor
}
//Helper function that draws a line which is used while the user is selecting letters
func drawLine(fromPoint: CGPoint, toPoint: CGPoint){
UIGraphicsBeginImageContext(drawingView.frame.size)
//get a context
guard let context: CGContext = UIGraphicsGetCurrentContext() else {
print("failed to get image context to draw line")
return
}
self.drawingView.image?.draw(in: CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height))
//set the line
context.move(to: fromPoint)
context.addLine(to: toPoint)
//customize line
context.setLineCap(CGLineCap.round)
context.setLineWidth(4)
context.setStrokeColor(UIColor.blue.cgColor)
context.setBlendMode(CGBlendMode.normal)
//draw the line
context.strokePath()
drawingView.image = UIGraphicsGetImageFromCurrentImageContext()
//close
UIGraphicsEndImageContext()
//print("Drew Line")
}
//helper function that initilizes the board layout
func initBoard() {
for i in 0 ... 11 {
for j in 0 ... 8 {
board[i][j] = UILabel()
board[i][j].text = ""
}
}
}
/**
This function takes the generated board strings and converts them into their proper labels
*/
func generateLabels() {
highlightedArea = []
for i in 0 ... 11 {
for j in 0 ... 8 {
//board[i][j] = UILabel()
board[i][j].textAlignment = .center
board[i][j].layer.borderColor = UIColor.black.cgColor
board[i][j].layer.borderWidth = 1.0;
if boardStrings[i][j].hasSuffix("^") {//.characters.contains("^"){
//highlight the label
board[i][j].backgroundColor = UIColor.green
//set the text
let index = boardStrings[i][j].index(boardStrings[i][j].startIndex, offsetBy: boardStrings[i][j].count - 1)
let range = boardStrings[i][j].startIndex..<index
let letter = String(boardStrings[i][j][range])
board[i][j].backgroundColor = UIColor.green
board[i][j].text = letter
highlightedArea.append((row: i, column: j))
}
else if boardStrings[i][j] != "Blank" {
board[i][j].backgroundColor = unHighlightedColor
board[i][j].text = boardStrings[i][j]
}
else {
//leave it blank
board[i][j].backgroundColor = unHighlightedColor
board[i][j].text = ""
}
}
}
}
}
Recently experienced the same problem. It might be too late to help you out, but hopefully someone finds this useful someday.
Using floor() for height and width when setting the image view's bounds(image view you want to draw into) worked for me. Did this in objective C... don't know the floor() equivalent for swift.