'Make Finite CircleSlider SwiftUI
There is the circle slider that turns back to 1 degree when it passes 360 degrees (the same relates to the case when I move slider from 360 to 1 - it then turns into 360). Please advise how to make the slider stop at 360 (its max value) when I move it clockwise, and to stop at 1 degree (min value) when it gets moved anticlockwise.
//
// Control.swift
// Gesture
//
// Created by Gleber on 11.04.2021.
//
import SwiftUI
struct Control2: View {
var size = UIScreen.main.bounds.width - 100
@State var progress: CGFloat = 0.400461
@State var angle: Double = 143.55555
var colors: [Color] = [.green, .purple]
var body: some View {
VStack {
ZStack {
Circle()
.stroke(Color.gray, style: StrokeStyle(lineWidth: 55, lineCap: .round, lineJoin: .round))
.frame(width: size, height: size)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.green/*colors[workinFile.colorNum-1]*/, style: StrokeStyle(lineWidth: 55, lineCap: .butt))
.frame(width: size, height: size)
.rotationEffect(.init(degrees: -90))
// .shadow(radius: 10)
.shadow(color: .white,radius: 5)
Circle()
.fill(Color.gray)
.frame(width: 55, height: 55)
.offset(x: size / 2)
.rotationEffect(.init(degrees: -90))
Circle()
.fill(Color.white)
.frame(width: 55, height: 55)
.offset(x: size / 2)
.rotationEffect(.init(degrees: angle))
.gesture(DragGesture().onChanged(onDrag(value:)))
.rotationEffect(.init(degrees: -90))
Button(action: {print(Int(correction(oldValue: progress)))}, label: {
ZStack {
Circle()
.fill(Color.dynamicColor1)
.frame(width: size / 1.5, height: size / 1.5)
.overlay(Circle().stroke(Color.dynamicColor1, lineWidth: 4))
.shadow(color: .dynamicShadow1, radius: 7)
VStack {
Text(String(format: "%.0f", correction(oldValue: progress)))
.font(.largeTitle)
.fontWeight(.heavy)
}
}
})
}
.preferredColorScheme(.dark)
}
}
func onDrag(value: DragGesture.Value) {
// calculating radians...
let vector = CGVector(dx: value.location.x, dy: value.location.y)
// since atan2 will give from -180 to 180...
// eliminating drag gesture size
// size = 55 => Radius = 27.5...
let radians = atan2(vector.dy - 27.5, vector.dx - 27.5)
print(radians)
// converting to angle...
var angle = radians * 180 / .pi
// simple technique for 0 to 360...
// eg = 360 - 176 = 184..
if angle < 0{
angle = 360 + angle
}
// angle = max(CGFloat(self.angle),angle)
if angle < 360 {
self.angle = Double(angle)
}
// if Double(maxAngle).rounded(toPlaces: 0) - Double(angle).rounded(toPlaces: 0) > 330 {
// model.angle = Double(maxAngle)
// }
withAnimation(Animation.linear(duration: 0.15)){
// progress...
let progress = angle / 360
self.progress = progress
}
}
func correction(oldValue: CGFloat) -> CGFloat {
return oldValue * 300 + 200
}
}
struct Control2_Previews: PreviewProvider {
static var previews: some View {
Control2()
}
}
extension Color {
static let dynamicColor1 = Color(UIColor { traitCollection in
return traitCollection.userInterfaceStyle == .dark ? .black : .white
})
static let dynamicShadow1 = Color(UIColor { traitCollection in
return traitCollection.userInterfaceStyle == .dark ? .white : .black
})
}
extension Double {
func rounded1(toPlaces places:Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
Solution 1:[1]
For the original author, or anyone who may come upon this issue, I've used this as a base for a control that I'm building. To solve this problem, I kept track of the quadrant that the indicator is in and which quadrant it's going to. If it were to go past the boundary, the indicator snaps to the appropriate angle (360 or 0 appropriately depending on the direction of the movement – could easily be adjusted to snap to 1º rather than 0). The code below is self contained and is highly (likely excessively) configurable. It's very possible I missed something and there are unaccounted for issues, but this is what I'm using as of now.
//
// Rotating Dial.swift
//
// Created by Ryan on 3/18/22.
// Based on https://stackoverflow.com/questions/67258304/make-finite-circleslider-swiftui
import SwiftUI
import GLKit
struct RotatingDial: View {
//MARK: - Configurable properties
var lineThickness:LineThickness = .max
var indicatorDiameter = 50.0
var lineWidth:Double {
return indicatorDiameter * lineThickness.rawValue
}
var circleSizeMultiplier = 4
var scaledCircleSize: Double {
let manualScaling = indicatorDiameter * Double(circleSizeMultiplier)
let boundingScaling = UIScreen.main.bounds.width - 100
return min(manualScaling, boundingScaling)
}
///A forced size for the dial specified by the caller
var dialSize: Double?
var intendedDialSize: Double {
dialSize ?? scaledCircleSize
}
var canRotateLessThan0 = false
var canRotateMoreThan360 = false
var showProgress = true
//MARK: - Internal State
@State var progress: Double = 0
@State var angle: Double = 0
@State var currentQuadrant:Quadrant = .one
@State var upcomingQuadrant:Quadrant = .one
//MARK: - View
var body: some View {
VStack {
Spacer()
ZStack {
dialOutline
if showProgress {
currentProgressFill
}
angleIndicator
centerButton
}
Spacer()
}
}
func onDrag(value: DragGesture.Value) {
currentQuadrant = upcomingQuadrant
let dx = value.location.x
let dy = value.location.y
//Atan2 at the edge of the line, removing the radius of the indicator line and atan2 will give from -180 to 180
let radians = atan2(dy - (0.5 * indicatorDiameter),
dx - (0.5 * indicatorDiameter))
var dragAngle = Double(GLKMathRadiansToDegrees(Float(radians)))
// simple technique for 0 to 360... eg = 360 - 176 = 184..
if dragAngle < 0 {
dragAngle = 360 + dragAngle
}
let futureQuadrant = quadrant(x: Sign.of(dx), atan2: Sign.of(radians))
if shouldSnapTo0(from: currentQuadrant, to: futureQuadrant) {
setAngleOfIndicator(to: 0)
} else if shouldSnapTo360(from: currentQuadrant, to: futureQuadrant) {
setAngleOfIndicator(to: 360)
} else if dragAngle <= 360 {
self.upcomingQuadrant = futureQuadrant
setAngleOfIndicator(to: dragAngle)
}
}
}
//MARK: - Supporting Views
extension RotatingDial {
///The gray outline for the dial. This will have a thickness of `lineWidth` and a
///frame that encapsulates `circleSize`
var dialOutline: some View {
Circle()
.stroke(Color.gray,
style: StrokeStyle(lineWidth: lineWidth,
lineCap: .round,
lineJoin: .round))
.frame(width: intendedDialSize, height: intendedDialSize)
}
///The green fill representing the current progress.
var currentProgressFill: some View {
Circle()
.trim(from: 0, to: progress)
.stroke(Color.green,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.frame(width: intendedDialSize, height: intendedDialSize)
.rotationEffect(.init(degrees: -90))
// .shadow(color: .secondary,radius: 50)
}
/// The circle representing the angle that is currently chosen. It will have a frame
/// encapsulating `indicatorDiameter` and starts at the `angle` with 0º
/// indicated at the top.
var angleIndicator: some View {
Circle()
.fill(Color.primary)
.frame(width: indicatorDiameter, height: indicatorDiameter)
.offset(x: intendedDialSize / 2) //put indicator circle on the edge
.rotationEffect(.init(degrees: angle))//modification for it to rotate angle chosen
.gesture(DragGesture().onChanged(onDrag(value:)))
.rotationEffect(.init(degrees: -90)) //Offset the indicator to compensate for initial SwiftUI coordinate drift
}
/// A central button that indicates the current angle measurement. Its frame accounts
/// for the size of the `indicatorDiameter`
var centerButton: some View {
Button(action: {},
label: {
ZStack {
Circle()
.fill(Color.tertiarySystemBackground)
.frame(width: intendedDialSize - indicatorDiameter - lineWidth,
height: intendedDialSize - indicatorDiameter - lineWidth)
.overlay(Circle().stroke(Color.tertiarySystemBackground, lineWidth: lineWidth))
Text(String(format: "%.00f", angle))
}
})
}
}
//MARK: - Supporting Types
extension RotatingDial {
///Supporting type to represent the sign of a number.
///Helps with pattern matching and exhaustive switches
enum Sign {
case positive, negative
///Positive is defined as >= 0.
static func of(_ num:Double) -> Sign {
if num >= 0 {
return positive
}
else {
return negative
}
}
}
///A standardized thickness of the line drawn
enum LineThickness:Double {
case veryThin = 0.1,
thin = 0.3,
regular = 0.5,
thick = 0.7,
max = 1.0
}
///Quadrants of a circle
enum Quadrant:Int {
case one = 1, two, three, four
}
}
//MARK: - Supporting methods
extension RotatingDial {
///Reveals the quadrant of a circle from an x coordinate and atan2
func quadrant(x:Sign, atan2:Sign) -> Quadrant {
switch (x, atan2) {
case (.positive, .positive):
return .one
case (.negative, .positive):
return .two
case (.negative, .negative):
return .three
case (.positive, .negative):
return .four
}
}
///Sets the angle and progress
func setAngleOfIndicator(to angle:Double) {
self.angle = angle
self.progress = angle/360.0
}
///Whether or not this dial should stop at and snap to 360º
func shouldSnapTo360(from currentQuadrant:Quadrant,
to upcomingQuadrant:Quadrant) -> Bool {
!canRotateMoreThan360 && //configuration
currentQuadrant == .four && upcomingQuadrant == .one && // in the correct Quadrant?
progress > 0.8 //Make sure we don't snap too soon
}
///Whether or not this dial should stop at and snap to 0º
func shouldSnapTo0(from currentQuadrant:Quadrant,
to upcomingQuadrant:Quadrant) -> Bool {
!canRotateLessThan0 && //configuration
currentQuadrant == .one && upcomingQuadrant == .four && // in the correct Quadrant?
progress < 0.2 //Make sure we don't snap too soon
}
}
//MARK: - Preview
struct RotatingDial_Previews: PreviewProvider {
static var previews: some View {
RotatingDial().preferredColorScheme(.light)
RotatingDial().preferredColorScheme(.dark)
RotatingDial(lineThickness:.thin,
indicatorDiameter: 30,
dialSize:100).preferredColorScheme(.dark)
}
}
extension Color {
static let systemBackground = Color(UIColor.systemBackground)
static let secondarySystemBackground = Color(UIColor.secondarySystemBackground)
static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground)
static let systemFill = Color(UIColor.systemFill)
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 |