Rotary Knob with SwiftUI - ios

So I'm attempting to replicate the normal SwiftUI slider functionality with a rotary knob. I've got the UI coded up and functioning currently connected to a standard SwiftUI slider in order to rotate it.
Now I need to add the rest of the slider functionality(ie $value, range, stride) and the touch functionality(ie knob rotating when dragging up and down, left and right). And Honestly I'm at a lost on the best way to do this. Any help is greatly appreciated.
Below is the main file and the project can be found here on Github Slider Project
//
// FKKnob.swift
//
// Created by Brent Brinkley on 3/7/21.
//
import SwiftUI
struct FKKnob: View {
// Set the color for outer ring and inner dash
let color: Color
// Minimum appearance value:
let circleMin: CGFloat = 0.0
// Maximum appearance value
let circleMax: CGFloat = 0.9
// Because our circle is missing a chunk of degrees we have to account
// for this adjustment
let circOffsetAmnt: CGFloat = 1 / 0.09
// Offset needed to align knob properly
let knobOffset: Angle = .degrees(110)
// calculate the our circle's mid point
var cirMidPoint: CGFloat {
0.4 * circOffsetAmnt
}
// User modfiable control value
#State var value: CGFloat = 0.0
var body: some View {
VStack {
ZStack {
// MARK: - Knob with dashline
Knob(color: color)
.rotationEffect(
// Currently controlled by slider
.degrees(max(0, Double(360 * value )))
)
.gesture(DragGesture(minimumDistance: 0)
.onChanged({ value in
// Need help here setting amount based on x and y touch drag
}))
// MARK: - Greyed Out Ring
Circle()
.trim(from: circleMin, to: circleMax)
.stroke(Color.gray ,style: StrokeStyle(lineWidth: 6, lineCap: .round, dash: [0.5,8], dashPhase: 20))
.frame(width: 100, height: 100)
// MARK: - Colored ring inidicating change
Circle()
.trim(from: circleMin, to: value)
.stroke(color ,style: StrokeStyle(lineWidth: 6, lineCap: .round, dash: [0.5,8], dashPhase: 20))
.frame(width: 100, height: 100)
}
.rotationEffect(knobOffset)
Text("\(value * circOffsetAmnt, specifier: "%.0f")")
Slider(value: $value, in: circleMin...circleMax)
.frame(width: 300)
.accentColor(.orange)
}
}
}
struct DashedCircle_Previews: PreviewProvider {
static var previews: some View {
FKKnob(color: Color.orange)
}
}

Here's a version that I use in one of my projects.
When the drag starts, it sets an initial value (stored in startDragValue). This is because you always want the modification of the value to be based on what the knob value was when you started.
Then, I've made the decision to only change values based on the y axis. The rational is this: one could change based on absolute distance from x1, y1 to x2, y2, but you run into problems with trying to get negative values. For instance, it would probably make sense that the upper right quadrant would be an overall increase -- but what about the upper left quadrant -- would it lead to positive change (because of the y axis change) or negative (because of the x axis)?
If you decide to go the route of x,y change, this method will still get you set up.
struct ContentView: View {
#State var value : Double = 0.0
#State private var startDragValue : Double = -1.0
var body: some View {
Text("Knob \(value)")
.gesture(DragGesture(minimumDistance: 0)
.onEnded({ _ in
startDragValue = -1.0
})
.onChanged { dragValue in
let diff = dragValue.startLocation.y - dragValue.location.y
if startDragValue == -1 {
startDragValue = value
}
let newValue = startDragValue + Double(diff)
value = newValue < 0 ? 0 : newValue > 100 ? 100 : newValue
})
}
}
My slider bases the values on 100pt up or down from the control, but you can obviously change those to your preferences as well.
In terms of range, I'd suggest always having the knob go from 0.0 to 1.0 and then interpolating the values afterwards.

I found the SwiftUI RotationGesture() to work perfectly for this in that it allows the intuitive rotation of a knob without the downsides listed in the answer above.
The only unique downside is that it maybe users have gotten used to single-finger knob operation, (especially on iPhones) and single-finger drag gesture may be the way to go if you have small knobs in your app that it's tough to get two fingers on.
Having said that, you could always attach multiple gestures and get both. Anyway, thanks to #jnpdx for the bit about future gestures starting from where you left off with startRotation, and without further ado:
import SwiftUI
struct KnobView: View {
#State private var rotation: Double = 0.0
#State private var startRotation: Double = 0.0
var body: some View {
Knob()
.rotationEffect(.degrees(rotation))
.gesture(
RotationGesture()
.onChanged({ angle in
if startRotation == 0 {
startRotation = rotation
}
rotation = startRotation + angle.degrees
})
.onEnded({ _ in
startRotation = 0
})
)
}
}
// convience Knob to checkout this Stack Overflow answer
// that can be later replaced with your own
struct Knob: View {
var body: some View {
RoundedRectangle(cornerRadius: 12)
.frame(width: 200, height: 200)
}
}
struct KnobView_Previews: PreviewProvider {
static var previews: some View {
KnobView()
}
}

Related

SwiftUI: animate scale of view back to initial state when done?

I have this simple view:
struct ContentView: View {
#State private var scale:Double = 1
var body: some View {
Rectangle()
.fill(.red)
.frame(width: 200, height: 200)
.scaleEffect(scale)
.animation(Animation.easeInOut(duration: 1).repeatCount(4, autoreverses: true).delay(1), value: scale)
.onAppear {
scale = 1.2
}
}
}
It produces this animation:
You'll notice at the end of the animation it snaps to scale 1.2 since that is the value of the state scale effect follows.
I am want to trigger the scale animation on appear, and make it "pulse" 4 times from state 1 to 1.2 and back, 4 times. Right now it does start at scale 1, pulses, but at the end it snaps to 1.2. How can I fix this so it creates a "pulse" animation that starts at 1, scales to 1.2 and back 4 times?

How to properly animate a Slider in SwiftUI?

I want to animate a Slider whenever its value changes with a custom animation (with a custom duration). But somehow it doesn't work. No matter what animation I specify, it always uses the same (very short) animation that is almost not noticeable.
Here's a minimal code example:
struct SliderView: View {
#State var value: Float = 0
var body: some View {
VStack {
Slider(value: $value)
.animation(.linear(duration: 3), value: value) // ← here 🎬
Button("Toggle") {
value = value < 0.5 ? 1 : 0
}
}
.frame(width: 300, height: 200)
.padding(16)
}
}
What am I doing wrong?
Note 1: Without the animation modifier, I get no animation at all and the knob immediately jumps to its new position. With the modifier, it animates but it doesn't seem to respect the concrete animation I specify.
Note 2: I know that I could use an explicit animation and wrap the change in a withAnimation closure. But even if that worked, it's not an option for me because the in my concrete case, the value is not a #State but a #Binding and is thus set externally from a view model that should not know anything about SwiftUI.

Using Dates as plot points on a Line Chart, how do you plot the marks to start at the beginning of a day in SwiftUI?

I cannot change the location of the Mark within the day.
I have tried changing the Marks and the x axis values (which are Date objects) to the start of the day, but it doesn't change the plot point. I assume it is rounding my Dates to the middle of the day (noon). How do I make my marks appear at the beginning of the day?
Notice the marks in my line chart do not start at the bottom left, and each mark never appears on a vertical line.
struct PackagesReceivedChart: View {
var plottablePackageData: [PlottablePackageData]
var calendarComponent: Calendar.Component
var xAxisDates : [Date]
let underGradient = LinearGradient(
gradient: Gradient (
colors: [
.red.opacity(0.7),
.red.opacity(0.5),
.red.opacity(0.3),
]
),
startPoint: .top,
endPoint: .bottom
)
var body: some View {
Chart{
ForEach(plottablePackageData){
LineMark(
x: .value("time", $0.timeframe, unit: calendarComponent),
y: .value("packagesReceived", $0.count)
)
.foregroundStyle(.red)
.lineStyle(StrokeStyle(lineWidth: 3))
.symbol(){
Circle()
.fill(.red)
.frame(width: 10)
}
AreaMark(x: .value("time", $0.timeframe, unit: calendarComponent),
y: .value("packagesReceived", $0.count))
.interpolationMethod(.linear)
.foregroundStyle(underGradient)
}
}
.chartXAxis{
AxisMarks(values: xAxisDates)
}
.chartYAxis{
AxisMarks(position: .leading)
}
.chartForegroundStyleScale([
"Packages Received" : .red
])
}
}
Calendar.Component in this example is .weekday

SwiftUI transmit drag focus to outer view (ScrollView inside dragable)

What I want
I have a "ScrollView" in a custom sheet that I can drag down to close. Of course, the ScrollView is above the drag area, so when I drag over the ScrollView it scrolls down or up. I want to disable the ScrollView when I am at the top of the ScrollView and scroll up so that the sheet is dragged and starts to close. This is similar to the behaviour of the Shazam sheet.
The Problem
If the ScrollView is deactivated, the current drag action is not applied to the sheet, but does nothing. Only when dragging again (on the now deactivated ScrollView) the sheet is focused on the dragging. So is there a way to transfer the focus of the drag action from the ScrollView to the outer sheet view without starting a new one?
Showcase
I created a simplified version of the problem for better understanding.
We have a container (inner) that is draggable along the y-axis. On drag release, we let it jump back to offset 0.
#State var offsetY: CGFloat = .zero
var body: some View {
Inner(outerOffset: $offsetY)
.offset(y: offsetY)
.gesture(DragGesture().onChanged { value in
offsetY = value.translation.height
}.onEnded { _ in
offsetY = 0
}
)
}
Inside the container we have our own ScrollView (ScrollViewOffset) which takes a callback with the current scroll offset. If the offset is positive (i.e. if we are scrolling upwards, even if we are at the top of the content), we want to disable the scroll and let the outer container drag along the y-axis instead of the ScrollView. To activate the ScrollView we listen for the outerOffset (the drag value) and activate the ScrollView when the offset is 0 again (this happens when releasing the drag as described before).
struct Inner: View {
#Binding var outerOffset: CGFloat
#State var disableScroll = false
var body: some View {
ScrollViewReader { proxy in
ScrollViewOffset {
ForEach(0 ... 10, id: \.self) { e in
Text(String(e))
.id(e)
.padding()
Divider()
}
} onOffsetChange: { offset in
if offset > 0 {
disableScroll = true
}
}
.background(.red)
.padding()
.frame(width: nil, height: 400)
.onChange(of: outerOffset) { _ in
if outerOffset == 0 {
proxy.scrollTo(0)
disableScroll = false
}
}
.disabled(disableScroll)
}
}
}
When we run this, it is easy to see the problem I mentioned before. The ScrollView is getting disabled as it should, but the container is not focused on the drag. Only after starting a new drag, the container is focused.
My actual case
Last but not least the shazam equivalent (left) and my project (right).

SwiftUI - Environment Object Not Updating UI

My app has one button that produces either a green or red background. The user presses the button over and over to generate green or red each time and the results are recorded into an Observable Object called "Current Session." The current totals are displayed in a custom view made of capsules. For some reason, the view will not update the totals and keeps displaying 0.
I've tried adding a state binding for numberOfGreens in ResultsBar but that doesn't work either. It still just shows 0 every time. The environment has been added to all of my views. I've tried printing the results and the result numbers show up correct in the console but the UI will not update. Interestingly, the trialsRemaining variable works perfectly on other screens in my app, having no trouble updating when it has to. Also, ResultsBar works perfectly fine in preview.
class CurrentSession: ObservableObject {
#Published var trialsRemaining: Int = 100
#Published var numberOfGreens: Int = 0
#Published var numberOfReds: Int = 0
func generateGreenOrRed() {
...
if resultIsGreen {numberOfGreens += 1}
else if resultIsRed {numberOfReds += 1}
trialsRemaining -= 1
}
}
struct ResultsBar: View {
let totalBarWidth: CGFloat = UIScreen.main.bounds.width - 48
var onePercentOfTotalWidth: CGFloat { return totalBarWidth/100 }
var numberOfGreens: Int
var body: some View {
ZStack(alignment: .leading) {
Capsule()
.frame(width: totalBarWidth, height: 36)
.foregroundColor(.red)
.shadow(radius: 4)
Capsule()
.frame(width: (onePercentOfTotalWidth * CGFloat(numberOfGreens)), height: 36)
.foregroundColor(.green)
.shadow(radius: 4)
}
}
}
struct ResultsView: View {
#EnvironmentObject var session: CurrentSession
var body: some View {
VStack {
HStack {
Text("\(session.numberOfGreens)")
Spacer()
Text("\(session.numberOfReds)")
}
ResultsBar(numberOfGreens: session.numberOfGreens)
}
}
}
I am running Xcode 11.2 beta 2 (11B44) on macOS Catalina 10.15.2 Beta (19C32e).
Are you passing a reference to a CurrentSession instance to your ResultsView? If I copy your code, the following does update the ResultsBar when I click on it.
struct ContentView: View {
#ObservedObject var session = CurrentSession()
var body: some View {
ResultsView()
.environmentObject(session)
.onTapGesture {
self.session.generateGreenOrRed()
}
}
}
I had this same issue, but my issue was because I was updating the environment object on a background thread because I'm using an api. In order to fix my issue I just needed to wrapped setting my EnvironmentObject vars like the following.
DispatchQueue.main.async{
self.numberOfGreens = GetNumberOfGreens(id: id)
}
I hope this helps someone.

Resources