Created
November 2, 2023 10:04
-
-
Save webserveis/c0634b61d12b41b68ec98c09663952d9 to your computer and use it in GitHub Desktop.
CircularSlider0to100
This file contains hidden or 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
// | |
// CircularSlider.swift | |
// CircularSliderTest | |
// | |
// Created by Tarlan Ismayilsoy on 28.08.22. | |
// | |
import SwiftUI | |
// MARK: - CircularSlider | |
/// A view that can be interacted by dragging a knob over a circular path to select a value | |
public struct CircularSlider: View { | |
/// The current value of the slider | |
@Binding var currentValue: Double | |
/// The minumum value that can be set | |
var minValue: Double = 0 | |
/// The maximum value that can be set. Must be larger than `minValue` | |
var maxValue: Double = 100 | |
/// The radius of the knob | |
var knobRadius: Double = 11 | |
/// The color of the knob | |
var knobColor: Color = .white | |
/// The radius of the whole slider | |
var radius: Double = 80 | |
/// The color of the line that is drawn as the knob moves | |
var progressLineColor: Color = .green | |
/// The color of the track on which the knob moves | |
var trackColor: Color = .gray.opacity(0.2) | |
/// The line width used for the progress line (the line that is drawn upon drag) | |
var lineWidth: Double = 5 | |
/// The font used for showing the current value | |
var font: Font = .system(size: 30) | |
/// The color of the text shown in the middle | |
var textColor: Color = .primary | |
/// The background color of the slider | |
var backgroundColor: Color = .clear | |
/// The radius of the background | |
var backgroundRadius: Double = 100 | |
/// Controls whether a `Text` showing the current value should be shown | |
var showsCurrentValueAsText = true | |
/// A string that precedes the current value. Shown only if `showsCurrentValueAsText` is set to true | |
var currentValuePrefix = "" | |
/// A string that follows the current value. Shown only if `showsCurrentValueAsText` is set to true | |
var currentValueSuffix = "" | |
/// The angle of the circle that should be filled | |
@State private var angle: Double = 0 | |
public init(currentValue: Binding<Double>, minValue: Double = 0, maxValue: Double = 100, knobRadius: Double = 11, knobColor: Color = .white, radius: Double = 80, progressLineColor: Color = .green, trackColor: Color = .gray.opacity(0.2), lineWidth: Double = 5, font: Font = .system(size: 30), textColor: Color = .primary, backgroundColor: Color = .clear, backgroundRadius: Double = 100, showsCurrentValueAsText: Bool = true, currentValuePrefix: String = "", currentValueSuffix: String = "") { | |
self._currentValue = currentValue | |
self.minValue = minValue | |
self.maxValue = maxValue | |
self.knobRadius = knobRadius | |
self.knobColor = knobColor | |
self.radius = radius | |
self.progressLineColor = progressLineColor | |
self.trackColor = trackColor | |
self.lineWidth = lineWidth | |
self.font = font | |
self.textColor = textColor | |
self.backgroundColor = backgroundColor | |
self.backgroundRadius = backgroundRadius | |
self.showsCurrentValueAsText = showsCurrentValueAsText | |
self.currentValuePrefix = currentValuePrefix | |
self.currentValueSuffix = currentValueSuffix | |
} | |
public var body: some View { | |
ZStack { | |
Circle() // background | |
.foregroundColor(backgroundColor) | |
.frame(width: backgroundRadius * 2, height: backgroundRadius * 2) | |
Circle() // track line (static, remains in the background) | |
.stroke(trackColor, | |
style: StrokeStyle(lineWidth: lineWidth * 1.3, lineCap: .butt)) | |
.frame(width: radius * 2, height: radius * 2) | |
Circle() // progress line (dynamic, follows the knob) | |
.trim(from: 0.0, to: valueAsPercentage(value: currentValue)) | |
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) | |
.foregroundColor(progressLineColor) | |
.frame(width: radius * 2, height: radius * 2) | |
.rotationEffect(.degrees(-90)) | |
Circle() // knob | |
.fill(knobColor) | |
.shadow(radius: 1) | |
.frame(width: knobRadius * 2.5, height: knobRadius * 2.5) | |
.padding(10) | |
.offset(y: -radius) | |
.rotationEffect(Angle.degrees(angle)) | |
Circle() // knob (invisible, larger, makes user interaction easier) | |
.fill(.blue.opacity(0.000001)) | |
.frame(width: knobRadius * 6, height: knobRadius * 6) | |
.padding(10) | |
.offset(y: -radius) | |
.rotationEffect(Angle.degrees(angle)) | |
.gesture(DragGesture(minimumDistance: 0.0) | |
.onChanged({ value in | |
change(location: value.location) | |
})) | |
if showsCurrentValueAsText { | |
Text("\(currentValuePrefix + String.init(format: "%.0f", currentValue) + (currentValueSuffix))") | |
.font(font) | |
.minimumScaleFactor(0.01) | |
.foregroundColor(textColor) | |
} | |
} | |
.onAppear { | |
currentValue = currentValue < minValue ? minValue : currentValue | |
currentValue = currentValue > maxValue ? maxValue : currentValue | |
angle = valueToAngle(value: currentValue) | |
} | |
.onChange(of: currentValue) { newValue in // for supporting external changes | |
currentValue = newValue < minValue ? minValue : newValue | |
currentValue = newValue > maxValue ? maxValue : newValue | |
angle = valueToAngle(value: newValue) | |
} | |
} | |
/// Updates the angle and the value of the slider | |
private func change(location: CGPoint) { | |
// creating vector from location point | |
let vector = CGVector(dx: location.x, dy: location.y) | |
// geting angle in radian need to subtract the knob radius and padding from the dy and dx | |
let newAngle = atan2(vector.dy - (knobRadius * 4), vector.dx - (knobRadius * 4)) + .pi/2.0 | |
// convert angle range from (-π to π) to (0 to 2π) | |
let fixedAngle = newAngle < 0.0 ? newAngle + 2.0 * .pi : newAngle | |
// convert angle to value | |
let newValue = angleToValue(angleInRadians: fixedAngle) | |
let currentValueAsPercentage = valueAsPercentage(value: currentValue) | |
let diff = abs(newValue - currentValue) | |
let diffThreshold = 0.15 * (maxValue - minValue) | |
if currentValueAsPercentage > 0.9 | |
&& diff > diffThreshold { | |
// for smoothing 99% to 100% transition | |
currentValue = maxValue | |
} | |
else if currentValueAsPercentage < 0.1 | |
&& diff > diffThreshold { | |
// for preventing direct transition to 100 from the right semicircle (ccw movement) | |
currentValue = minValue | |
} | |
else { // default behavior | |
currentValue = newValue | |
angle = radiansToDegrees(fixedAngle) | |
} | |
} | |
/// Convert the given angle to value | |
private func angleToValue(angleInRadians: Double) -> Double { | |
let angleAsPercentage = angleInRadians / (2.0 * .pi) | |
return angleAsPercentage * (maxValue - minValue) + minValue | |
} | |
/// Convert the given value to angle | |
private func valueToAngle(value: Double) -> Double { | |
return 360 * valueAsPercentage(value: value) | |
} | |
/// Expresses the given value as a percentage of the [minvalue, maxValue] range | |
private func valueAsPercentage(value: Double) -> Double { | |
return (value - minValue) / (maxValue - minValue) | |
} | |
/// Convert the given radian value to degrees | |
private func radiansToDegrees(_ radians: Double) -> Double { | |
return radians * 180 / .pi | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment