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

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?

Related

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.

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 alignment of "rating" views in two separate rows in Form for macOS and iOS

Writing a Multiplatform app initially for macOS and iOS that uses a master detail view structure and within the detail view is a Form and many Sections, one of which contains two structs that represent a user's rating (of food) for enjoyment and consumption.
Here is the code for the enjoyment rating...
struct Rating: View {
#Environment(\.colorScheme) var colourScheme
#Binding var rating: Int
var body: some View {
HStack {
Text("Enjoyment")
.padding(.trailing, 12.0)
#if os(iOS)
Spacer()
#endif
ForEach(0..<5) { counter in
Image(systemName: rating > counter ? "star.fill" : "star")
.onTapGesture(count: 1) {
if rating == 1 {
rating = 0
}
else {
rating = counter + 1
}
}
// this line for image system name = "circle.fill" : "circle"
//.padding(.trailing, counter != 4 ? 2.5 : 0)
}
.shadow(color: colourScheme == .light ? .gray : .white, radius: colourScheme == .light ? 1.0 : 2.0, x: 0, y: 0)
}
.foregroundColor(Color.accentColor)
}
}
The consumption rating is very similar with minor changes.
So far these look good on iOS because the (iOS only) Spacer() pushes each Rating view to the right or trailing edge of the row and my .padding modifier hack for the circle image makes the spacing between each image "about right".
While I'm struggling to figure out how to align the "dots" for the macOS target, I'm also struggling to figure out how to align each image programmatically, so that if I changed the image the alignment would work.
See screenshots below (that illustrate how the five Images do not align).
iOS
macOS
I've read a few blogs on the .alignmentGuide modifier including Alignment guides in SwiftUI by Majid Jabrayilov.
It seems to be the way I should go but I'm stuck in my attempts on how to work this out.
I've added a comment about the use of Spacer() between your label and your rating control, but separately it's worth looking at the rating control itself.
Firstly, right now you're relying on the five elements sharing an HStack with the label, and using conditional padding logic within the loop to control the spacing between elements.
That part would be easier if you give your rating element its own HStack. That way, spacing between elements can be determined using the stack's spacing attribute without having to worry about whether or not you're on the last loop iteration. For example:
HStack(spacing: 2.5) {
ForEach(0..<5) {
Image(systemName: "circle")
// etc.
}
}
In terms of aligning the child elements of a rating view so that they align with a similar view below regardless of the symbol being used, you can constrain the frame width of each child element to be the same, regardless of what image they're displaying.
You can accomplish that by adding a .frame() modifier to each child element in the loop:
HStack {
ForEach(0..<5) {
Image(systemName: "xxx")
.frame(width: 40, alignment: .center)
}
}
You'd obviously need to pick a width that works for you - and you could mix this with a spacing attribute on the HStack as well.

SwiftUI Path is pixelated when in second Navigation View

I have a strange behavior on my SwiftUI app. I draw a Path with a linear gradient as stroke color.
This path is displayed like this in my view:
GeometryReader { proxy in
LinePath(width: proxy.size.width, height: proxy.size.height)
.stroke(gradient, lineWidth: 3)
}
.frame(height:200)
.padding()
(LinePath struct extends Shape and draw the Path)
When this code is displayed on first view, it draws correctly:
But if I display it in a second view after a NavigationLink, the drawn line is "pixelated":
As it may be difficult to understand and there is quite lot of code (for a web page), I did a demo project you can get here.
To test it, you can open testprojectApp.swift and comment/uncomment parts:
ContentView() // <--- Uncomment this to get the issue
//SubView() // <--- Uncomment this and it works
If you call SubView (where this LinePath is drawn), it works. If you call ContentView that will display a button to navigate to SubView, it draws a pixelated line.
Notes:
If I don't set a height, it is never pixelated (.frame(height:200)).
If I don't use a gradient but a simple color (as .blue), it is never pixelated.
EDIT: I've found a "workaround", to redraw the line 0.01s after it appeared and change its height. It visually "works" but of course it is not a good method...
#State var height: CGFloat = 0
var body: some View {
GeometryReader { proxy in
LinePath(width: proxy.size.width, height: proxy.size.height)
.stroke(gradient, lineWidth: 3)
}
.frame(height:height)
.padding()
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
height = 200
}
}
}
You can add .drawingGroup() and it seems to resolve the issue without having to use the delay:
GeometryReader { proxy in
LinePath(width: proxy.size.width, height: proxy.size.height)
.stroke(gradient, lineWidth: 3)
}
.frame(height:200)
.padding()
.drawingGroup() //<-- here

Rotary Knob with SwiftUI

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()
}
}

Resources