-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #24 from chenhaiteng/develop
Publish Knob
- Loading branch information
Showing
12 changed files
with
695 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
## Knob | ||
|
||
### Preview | ||
|
||
![Knob Demo](https://user-images.githubusercontent.com/1284944/120065810-e2138900-c0a5-11eb-8324-2fe340bb578f.gif) | ||
|
||
### Usage | ||
|
||
```swift | ||
// Baisc Knob drawing value along the circumference. | ||
@State knobValue : Double // default range: 0.0...1.0 | ||
Knob($knobValue) // Create a Knob with default mapping(LinearMapping) | ||
.addLayer(ArcKnobLayer() // Add ArcKnobLayer to draw circumference. | ||
.arcWidth(10.0) | ||
.arcColor(.blue.opacity(0.7))) | ||
.frame(width: 100.0, height: 100.0) | ||
``` | ||
<img src="https://user-images.githubusercontent.com/1284944/120065862-1d15bc80-c0a6-11eb-876f-687db7b35d00.gif" alt="drawing" width="200"/> | ||
|
||
--- | ||
|
||
```swift | ||
// A Knob drawing value along circular track. | ||
@State knobValue : Double // default range: 0.0...1.0. | ||
Knob($knobValue) // Create a Knob with default mapping(LinearMapping) | ||
.addLayer(RingKnobLayer() // Add RingKnobLayer as the track. | ||
.ringWidth(10.0) | ||
.ringColor(.red.opacity(0.5))) | ||
.addLayer(ArcKnobLayer() // Add ArcKnobLayer to draw circumference. | ||
.arcWidth(10.0) | ||
.arcColor(.blue.opacity(0.7))) | ||
.frame(width: 100.0, height: 100.0) | ||
``` | ||
<img src="https://user-images.githubusercontent.com/1284944/120066040-2bb0a380-c0a7-11eb-865e-e4f2220ffead.gif" alt="drawing" width="200"/> | ||
|
||
--- | ||
|
||
```swift | ||
// A Knob with rotate image | ||
@State knobValue : Double // default range: 0.0...1.0, the range of knob value depends on mapping object. | ||
Knob($knobValue) | ||
.addLayer(ImageKnobLayer(Image("SimpleKnob"))) | ||
.frame(width: 150, height: 150) | ||
``` | ||
<img src="https://user-images.githubusercontent.com/1284944/120066082-61ee2300-c0a7-11eb-97e5-4a64b0bd4e8e.gif" alt="drawing" width="200"/> | ||
|
||
And the image sample is following: | ||
|
||
<img src="https://user-images.githubusercontent.com/1284944/120066145-ac6f9f80-c0a7-11eb-9a46-20245ca15933.png" alt="drawing" width="200"/> | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
// | ||
// Knob.swift | ||
// | ||
// | ||
// Created by Chen-Hai Teng on 2021/5/21. | ||
// | ||
|
||
import SwiftUI | ||
|
||
extension CGPoint { | ||
static func -(left: CGPoint, right: CGPoint) -> CGVector { | ||
// The origin of View's coordinate is on left-top, adjust it to left-bottom to fit mathmatic behaviour. | ||
return CGVector(dx: left.x - right.x, dy: right.y - left.y) | ||
} | ||
} | ||
|
||
extension CGVector { | ||
// atan2 only holds when x > 0. | ||
// When x < 0, the angle apparent from the expression above is | ||
// pointing in the opposite direction of the correct angle, | ||
// and a value of π (or 180°) must be either added or subtracted | ||
// from θ to put the Cartesian point (x, y) into the correct quadrant | ||
// of the Euclidean plane. | ||
static func adjustedAtan2<T>(y: T ,x: T) -> T where T: BinaryFloatingPoint { | ||
let result = atan2(CGFloat(y), CGFloat(x)) | ||
return T(result + ((x < 0 && y < 0) ? 2*CGFloat.pi : 0)) | ||
} | ||
|
||
static func crossQuadrant34(v1: CGVector, v2: CGVector) -> Bool { | ||
return (v1.dy*v2.dy > 0 && v2.dx*v1.dx < 0) | ||
} | ||
|
||
static func angularDistance(v1: CGVector, v2: CGVector) -> Angle { | ||
let angle2 = adjustedAtan2(y: v2.dy, x: v2.dx) | ||
let angle1 = adjustedAtan2(y: v1.dy, x: v1.dx) | ||
if(crossQuadrant34(v1: v1, v2: v2)) { // v1, v2 cross quadrant 3 and 4 | ||
return Angle.radians(Double(atan2(v2.dy, v2.dx) - atan2(v1.dy, v1.dx))) | ||
} else { | ||
return Angle(radians: Double(angle2 - angle1)) | ||
} | ||
} | ||
|
||
func angle(_ shouldAdjust: Bool = false) -> Angle { | ||
return Angle.radians(Double(radians(shouldAdjust))) | ||
} | ||
|
||
func radians(_ shouldAdjust: Bool = false) -> CGFloat { | ||
return shouldAdjust ? CGVector.adjustedAtan2(y: dy, x: dx) : atan2(dy, dx) | ||
} | ||
|
||
func degrees(_ shouldAdjust: Bool = false) -> CGFloat { | ||
return CGFloat(angle(shouldAdjust).degrees) | ||
} | ||
} | ||
|
||
public struct Knob: View { | ||
private var layers: [AnyKnobLayer] = [] | ||
|
||
private var mappingObj: KnobMapping | ||
|
||
@Binding var value: Double | ||
@GestureState var currentVector: CGVector = .zero | ||
|
||
private var blueprint: Bool = false | ||
|
||
@State var nextVector: CGVector = .zero | ||
@State var deltaAngle: Angle = .zero | ||
@State var startVector: CGVector = .zero | ||
@State private var startValue: Double = .nan | ||
|
||
public init<F: BinaryFloatingPoint>(_ value: Binding<F>, _ mapping: KnobMapping = LinearMapping()) { | ||
_value = Binding<Double>(get: { | ||
Double(value.wrappedValue) | ||
}, set: { v in | ||
value.wrappedValue = F(v) | ||
}) | ||
mappingObj = mapping | ||
} | ||
public var body: some View { | ||
GeometryReader { geo in | ||
let center = CGPoint(x: geo.size.width/2, y: geo.size.height/2) | ||
let radius = min(geo.size.width, geo.size.height)/2.0 | ||
|
||
let pt = CGPoint(x: center.x + currentVector.dx, y: center.y - currentVector.dy) | ||
let startPt = CGPoint(x: center.x + startVector.dx, y: center.y - startVector.dy) | ||
ZStack { | ||
ForEach(layers.indices) { index in | ||
layers[index].degreeRange(mappingObj.degreeRange).degree(mappingObj.degree(from: value)).view.frame(width: geo.size.width, height: geo.size.height, alignment: .center) | ||
} | ||
Group { | ||
Path { p in | ||
p.move(to: CGPoint(x: center.x - radius, y: center.y)) | ||
p.addLine(to: CGPoint(x: center.x + radius, y: center.y)) | ||
p.move(to: CGPoint(x: center.x, y: center.y - radius)) | ||
p.addLine(to: CGPoint(x: center.x, y: center.y + radius)) | ||
p.addEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: 2*radius, height: 2*radius)) | ||
}.stroke(Color.blue.opacity(0.5)) | ||
Path { p in | ||
p.move(to: center) | ||
p.addLine(to: pt) | ||
}.stroke(Color.blue.opacity(0.5)) | ||
Path { p in | ||
p.move(to: center) | ||
p.addLine(to: startPt) | ||
}.stroke(Color.red.opacity(0.5)) | ||
}.if(!blueprint) { content in | ||
content.hidden() | ||
} | ||
}.contentShape(Circle()).gesture(DragGesture().onChanged({ value in | ||
if(currentVector != CGVector.zero) { | ||
if(startValue.isNaN) { | ||
startValue = self.value | ||
} | ||
startVector = value.startLocation - center | ||
nextVector = value.location - center | ||
|
||
let shouldAdjust = !CGVector.crossQuadrant34(v1: nextVector, v2: currentVector) | ||
|
||
let valueStart = KnobGestureRecord.Value(value: startValue, angle: startVector.angle(shouldAdjust)) | ||
|
||
let valueCurrent = KnobGestureRecord.Value(value: self.value, angle: currentVector.angle(shouldAdjust)) | ||
|
||
let valueNext = KnobGestureRecord.Value(angle: nextVector.angle(shouldAdjust)) | ||
|
||
if -nextVector.angle(shouldAdjust).degrees > mappingObj.degreeRange.upperBound { | ||
return | ||
} | ||
if -nextVector.angle(shouldAdjust).degrees < mappingObj.degreeRange.lowerBound { | ||
return | ||
} | ||
|
||
|
||
let record = KnobGestureRecord(start: valueStart, current:valueCurrent, next:valueNext) | ||
let newValue = mappingObj.newValue(record) | ||
if !newValue.isNaN { | ||
if(self.value != newValue) { | ||
self.value = newValue | ||
} | ||
} | ||
} | ||
}).onEnded({ value in | ||
nextVector = .zero | ||
deltaAngle = .zero | ||
startValue = .nan | ||
startVector = .zero | ||
}).updating($currentVector, body: { value, state, transaction in | ||
state = value.location - center | ||
})) | ||
|
||
} | ||
} | ||
} | ||
|
||
extension Knob : Adjustable { | ||
public func addLayer<L>(_ layer: L) -> Self where L : KnobLayer { | ||
setProperty { tmp in | ||
tmp.layers.append(AnyKnobLayer(layer)) | ||
} | ||
} | ||
|
||
public func mapping<T: KnobMapping>(with mapping: T) -> Self { | ||
setProperty { tmp in | ||
tmp.mappingObj = mapping | ||
} | ||
} | ||
|
||
public func blueprint(_ show: Bool) -> Self { | ||
setProperty { tmp in | ||
tmp.blueprint = show | ||
} | ||
} | ||
} | ||
|
||
struct KnobDemo: View { | ||
@State var valueSegmented: CGFloat = 0 | ||
@State var valueContiune: CGFloat = 0 | ||
@State var ringWidth: CGFloat = 5 | ||
@State var arcWidth: CGFloat = 5 | ||
@State var showBlueprint: Bool = false | ||
let gradient = AngularGradient(gradient: Gradient(colors: [Color.red, Color.blue]), center: .center) | ||
var body: some View { | ||
VStack { | ||
Spacer(minLength: 40) | ||
HStack { | ||
VStack { | ||
Knob($valueContiune).addLayer(RingKnobLayer().ringColor(Gradient(colors: [.red, .blue, .blue, .blue, .blue, .blue, .red])).ringWidth(ringWidth)).addLayer(ArcKnobLayer().arcWidth(arcWidth)).blueprint(showBlueprint) | ||
Slider(value: $valueContiune, in: 0.0...1.0) { | ||
Text(String(format: "value: %.2f", valueContiune)) | ||
} | ||
} | ||
VStack { | ||
Knob($valueSegmented).addLayer(RingKnobLayer().ringColor(Gradient(colors: [.blue, .red, .red, .red, .red, .red, .blue])).ringWidth(ringWidth)).addLayer(ArcKnobLayer().arcWidth(arcWidth)).blueprint(showBlueprint).mapping(with: SegmentMapping().stops([KnobStop(0.0, -215.0), KnobStop(1.0, 45.0), KnobStop(0.5, -90.0), KnobStop(0.2, 0.0), KnobStop(0.8, -180.0), KnobStop(0.3, -135)])) | ||
Slider(value: $valueSegmented, in: 0.0...1.0, step: 0.1) { | ||
Text(String(format: "value: %.2f", valueSegmented)) | ||
} | ||
} | ||
} | ||
Spacer(minLength: 40) | ||
Group { | ||
Slider(value: $ringWidth, in: 5.0...25.0, step: 1.0) { | ||
Text(String(format: "Ring Width: %.2f", ringWidth)) | ||
} | ||
Slider(value: $arcWidth, in: 5.0...25.0, step: 1.0) { | ||
Text(String(format: "Arc Width: %.2f", arcWidth)) | ||
} | ||
Toggle("blue print", isOn: $showBlueprint) | ||
} | ||
} | ||
} | ||
} | ||
|
||
struct Knob_Previews: PreviewProvider { | ||
static var previews: some View { | ||
KnobDemo() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// | ||
// ArcKnobLayer.swift | ||
// | ||
// | ||
// Created by Chen-Hai Teng on 2021/5/22. | ||
// | ||
|
||
import SwiftUI | ||
|
||
public struct ArcKnobLayer : KnobLayer { | ||
public var isFixed: Bool = false | ||
public var minDegree: Double = 0.0 | ||
public var maxDegree: Double = 0.0 | ||
private var _degree: CGFloat = 120.0 | ||
public var degree: CGFloat { | ||
get { | ||
return _degree | ||
} | ||
set { | ||
if newValue > CGFloat(maxDegree) { | ||
_degree = CGFloat(maxDegree) | ||
} else if newValue < CGFloat(minDegree) { | ||
_degree = CGFloat(minDegree) | ||
} else { | ||
_degree = newValue | ||
} | ||
} | ||
} | ||
|
||
public var view: AnyView { | ||
get { | ||
AnyView(ZStack { | ||
GeometryReader { geo in | ||
Path { p in | ||
let radius = min(geo.size.height, geo.size.width)/2.0 - arcWidth/2.0 | ||
p.addArc(center: CGPoint(x: geo.size.width/2, y: geo.size.height/2), radius: radius, startAngle: Angle.degrees(minDegree), endAngle: Angle.degrees(Double(degree)), clockwise: false) | ||
}.stroke(arcColor, lineWidth: arcWidth).opacity(0.5) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
private var arcWidth: CGFloat = 5.0 | ||
private var arcColor: Color = .white | ||
|
||
public init() {} | ||
} | ||
|
||
extension ArcKnobLayer : Adjustable { | ||
public func arcWidth<F>(_ width:F) -> Self where F: BinaryFloatingPoint { | ||
setProperty { tmp in | ||
tmp.arcWidth = CGFloat(width) | ||
} | ||
} | ||
|
||
public func arcColor(_ color:Color) -> Self { | ||
setProperty { tmp in | ||
tmp.arcColor = color | ||
} | ||
} | ||
} |
Oops, something went wrong.