in this simple reproduced code I abstracted a problem that I was experiencing in a bigger project.
What I am trying to do is create a Rectangle (successful) and draw multiple ellipses, each one bigger than the last, inside of the Rectangle (failure). Below is code that is ready to be copied and pasted... What am I doing wrong?
Updated code to fix unrelated issue
import SwiftUI
struct ContentView: View {
var body: some View {
MyShape()
.stroke()
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct MyShape: Shape {
#State private var horizontalMultiplier: CGFloat = 0.5
#State private var verticalMultiplier: CGFloat = 0.5
func path(in rect: CGRect) -> Path {
var path = Path()
path.addRect(rect)
var newPath = Path()
for _ in 0...5 {
let transform = CGAffineTransform(scaleX: 1 * horizontalMultiplier, y: 1 * verticalMultiplier)
newPath.addEllipse(in: rect, transform: transform)
path.addPath(newPath)
horizontalMultiplier += 0.25
verticalMultiplier += 0.25
}
return path
}
}
Move the declarations of the variables you're trying increment outside your loop -- they're getting recreated (and thus stay 0) each time right now.
Update, after edit: there doesn't appear to be a reason to try to use #State here -- just define the variables within path. If you do need mutable state over time, store it in the parent view and pass it through via parameters
struct MyShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.addRect(rect)
var newPath = Path()
var horizontalMultiplier: CGFloat = 0.5
var verticalMultiplier: CGFloat = 0.5
for _ in 0...5 {
let transform = CGAffineTransform(scaleX: 1 * horizontalMultiplier, y: 1 * verticalMultiplier)
newPath.addEllipse(in: rect, transform: transform)
path.addPath(newPath)
horizontalMultiplier += 0.25
verticalMultiplier += 0.25
}
return path
}
}
Related
I'm writing a SwiftUI code for a carousel, and I have a CarouselModel class that goes like this:
CarouselModel.swift
import Foundation
import SwiftUI
class CarouselModel: ObservableObject {
//MARK: - Properties
#Published private var imagesCount: Int
#Published private var currentImage: Int
#Published private var offsetMainAxis: CGFloat
private var screenSize: CGFloat
private var isHorizontal: Bool
private var padding: CGFloat
private var nextImage: Int {
return (self.currentImage+1) % self.imagesCount
}
private var prevImage: Int {
return (self.currentImage-1+self.imagesCount) % self.imagesCount
}
private var centralIndex: CGFloat {
return CGFloat(self.imagesCount-1)/2
}
public var computedOffset: CGFloat {
return (centralIndex-CGFloat(currentImage))*(screenSize-padding) + offsetMainAxis
}
init(imagesCount: Int, isHorizontal: Bool = true, padding: CGFloat = 20, currentImage: Int = 0) {
self.imagesCount = imagesCount
self.currentImage = currentImage
self.offsetMainAxis = 0
self.padding = padding
self.isHorizontal = isHorizontal
if isHorizontal {
self.screenSize = UIScreen.main.bounds.width
} else {
self.screenSize = UIScreen.main.bounds.height
}
}
//MARK: - Methods
public func getCurrentImage() -> Int {
return self.currentImage
}
public func goNext() -> Void {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = nextImage
}
}
public func goPrevious() -> Void {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = prevImage
}
}
public func onDragChange(offset: CGFloat) -> Void {
self.offsetMainAxis = offset
}
public func onDragEnd() -> Void {
if abs(offsetMainAxis) > 0.2*screenSize {
if offsetMainAxis > 0 {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
currentImage = prevImage
}
} else {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
currentImage = nextImage
}
}
} else {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
}
}
}
public func skipTo(number i: Int) {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = i
}
}
}
At this point every time I need to place a concrete Carousel in my app I create a CarouselView.swift file containing some View, and the thing goes more or less like this:
CarouselView.swift
import SwiftUI
struct Carousel: View {
#StateObject var carousel: CarouselModel
private var screenWidth: CGFloat = UIScreen.main.bounds.width
private var images: [String]
private let padding: CGFloat
private var descriptions: [String]?
#State private var imageSelected: [String:Bool]
init(images: [String], descriptions: [String]?, padding: CGFloat = 20) {
self.images = images
self.padding = padding
_carousel = StateObject(
wrappedValue:
CarouselModel(
imagesCount: images.count,
isHorizontal: true,
padding: padding
)
)
self.imageSelected = Dictionary(uniqueKeysWithValues: images.map{ ($0, false) })
guard let desc = descriptions else {
self.descriptions = nil
return
}
self.descriptions = desc
}
var body: some View {
// USE carousel TO DISPLAY SOME CAROUSEL IMPLEMENTATION
}
}
Now, a problem arises when inside some container I need to display a different set of images based on a #State private var selectedImgSet: Int and each set has a different amount of pictures in it. The point is, whenever I call CarouselView(...) inside the container and its parameters change, its init is invoked but the CarouselModel doesn't get updated with the new images count.
This is totally expected since it is a #StateObject but I'm not sure how the update should happen. I can't use the .onAppear(perform: ) hook in the CarouselView since that is executed only once, when the CarouselView first appears.
What is the correct way to update the carousel: CarouselModel variable whenever CarouselView's init parameters change?
EDIT: Here are pastes for a full working examples: ContentView, CarouselItem, CarouselModel, CarouselView, Items. Add the following images sets to your assets: item00, item01, item02, item10, item11, item20, item21, item22, item23. Any 1920x1080p placeholder will do the trick for now.
I eventually fixed my issue the following way: I introduced the following method in CarouselModel:
public func update(_ images: Int) {
print("Performing update with \(images) images")
self.imagesCount = images
self.currentImage = 0
self.offsetMainAxis = 0
}
Then I added to the outmost View inside CarouselView the following modifier:
.onChange(of: self.images) { v in
self.carousel.update(v.count)
}
Now the update happens as expected.
You've got things the wrong way round. Create the #StateObject in the View above Carousel View and pass it in as #ObservedObject. However, since your model does no loading or saving it doesn't need to be a reference type, i.e. an object. A value type will suffice, i.e. a struct, so it can be #State in the parent and pass in as #Binding so you can call its mutating functions.
I am trying to make a simple SWiftUI ScrollView where I can set and get the value of the ScrollView bounds offset via a binding. I have the following which compiles and works fine as a ScrollView but I am unable to actually set and get the offset and propagate it back to the ContentView where the ScrollView is hosted.
I have the following:
struct MyScrollView<Content>: NSViewRepresentable where Content: View {
private var content: Content
let offset: Binding<CGFloat>
init(offset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.content = content()
self.offset = offset
}
func makeNSView(context: NSViewRepresentableContext<MyScrollView>) ->TheScrollView {
let view = TheScrollView(offset: offset)
view.hasVerticalScroller = true
view.hasHorizontalScroller = true
let document = NSHostingView(rootView: content)
document.translatesAutoresizingMaskIntoConstraints = false
view.documentView = document
return view
}
func updateNSView(_ view: TheScrollView, context: NSViewRepresentableContext<MyScrollView>) {
}
}
class TheScrollView: NSScrollView, ObservableObject{
private var subscriptions: Set<AnyCancellable> = []
var offset: Binding<CGFloat>
init(offset: Binding<CGFloat>){
self.offset = offset
super.init(frame: .zero)
NotificationCenter.default
.publisher(for: NSScrollView.boundsDidChangeNotification, object: self.contentView.documentView)
.sink() { _ in
let view = self.contentView
print(view.bounds.origin.y) // <- I do get this
self.offset.wrappedValue = view.bounds.origin.y // This does nothing
}
.store(in: &subscriptions)
}
required init?(coder: NSCoder){
fatalError("init(coder:) has not been implemented")
}
}
MyScrollView is hosted in a contentView like this:
import SwiftUI
import Combine
struct ContentView: View{
#State var offset: CGFloat = 10.0{
didSet{
print("Offset \(offset)")
}
}
var body: some View{
MyScrollView(offset: $offset){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: 1200, height: 1000)
Rectangle().foregroundColor(.blue).frame(width: 100, height: 100)
}
}
}
}
As you can see the offset value is passed from the #State var into MyScollView and then into TheScrollView, which is a subclass of NSScrollView. From there I have a simple notification to get the bounds change and set the binding. However setting the binding does nothing to the actual value in the binding and it definitely doesn't propagate back to the ContentView. Also, the address of offset changes up the hierarchy so it looks like I am passing a Binding to a Binding into TheScrollView rather than the original binding, but I cant seem to fix it.
Can anyone see what I am doing wrong?
It is State - it is updated when is used in body, so instead use like below:
struct ContentView: View{
#State var offset: CGFloat = 10.0
var body: some View {
VStack {
Text("Offset: \(offset)") // << here !!
MyScrollView(offset: $offset){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: 1200, height: 1000)
Rectangle().foregroundColor(.blue).frame(width: 100, height: 100)
}
}
}
}
}
I want the effect of a rotating record that has some ease to it whenever it starts and stops rotating. In code below the trigger is the isRotating Bool.
But I guess it's not possible to animate the speed of an animation?
struct PausableRotatingButtonStyle: ButtonStyle {
var isRotating: Bool
#State private var speed: Double = 1.0
#State private var degrees: Double = 0.0
var foreverAnimation: Animation {
Animation.linear(duration: 2)
.repeatForever(autoreverses: false)
.speed(speed)
}
func makeBody(configuration: Configuration) -> some View {
VStack {
Text("speed: \(speed.description)")
configuration.label
.rotationEffect(Angle(degrees: degrees))
.animation(foreverAnimation)
.onAppear {
degrees = 360.0
}
.onChange(of: isRotating) { value in
withAnimation(.linear) {
speed = value ? 1 : 0
}
}
}
}
}
struct TestRotatingButtonStyle_Previews: PreviewProvider {
static var previews: some View {
TestRotatingButtonStyle()
}
struct TestRotatingButtonStyle: View {
#State private var isPlaying: Bool = true
var body: some View {
VStack {
Button {
isPlaying.toggle()
} label: {
Text("💿")
.font(.system(size: 200))
}
.buttonStyle(PausableRotatingButtonStyle(isRotating: isPlaying))
}
}
}
}
If .easeOut and .spring options don't cut it, you can make a timing curve. This function accepts x and y values for two points (c0, c1).
These points define anchors that (choose a mental model verb: stretch/form/define) a cubic animation timing curve between the start and end points of your animation. (Just like drawing a path between 0,0 and 1,1. If this still sounds like gibberish, look at the objc.io link below for visuals.)
Image("wheel")
.animation(.timingCurve(0, 0.5, 0.25, 1, duration: 2))
An ease-in-out type curve could be .timingCurve(0.17, 0.67, 0.83, 0.67)
https://cubic-bezier.com/#.42,0,.58,1
You can read more via the objc.io guys.
https://www.objc.io/blog/2019/09/26/swiftui-animation-timing-curves/
Edit re: comment on speed
While timing is the intended API, you might be able to change speed in response to a binding from a GeometryEffect progress reporter.
In the animation below, I apply or remove the shadow beneath the ball based on the progress of the vertical-sin-wave-travel GeometryEffect. The progress value is between 0 and 1. (Takeoff/flight/landing is achieved by another boolean and animation curve for x-axis offset.)
[
/// Ball
.modifier(BouncingWithProgressBinding(
currentEffect: $currentEffectSize, // % completion
axis: .vertical,
offsetMax: flightHeight,
interationProgress: iteration
).ignoredByLayout())
struct BouncingWithProgressBinding: GeometryEffect {
#Binding var currentEffect: CGFloat // % completion
var axis: Axis
var offsetMax: CGFloat
var interationProgress: Double
var animatableData: Double {
get { interationProgress }
set { interationProgress = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let progress = interationProgress - floor(interationProgress)
let curvePosition = cos(2 * progress * .pi)
let effectSize = (curvePosition + 1) / ( .pi * 1.25 )
let translation = offsetMax * CGFloat(1 - effectSize)
DispatchQueue.main.async { currentEffect = CGFloat(1 - effectSize) }
if axis == .horizontal {
return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0))
} else {
return ProjectionTransform(CGAffineTransform(translationX: 0, y: translation))
}
}
}
So I have a view of lines. The number of lines are determined by the user's input. I know about creating lines and how to render those lines with different effects.. but how would I modify a line that has already been created.
So for example I would like to make the second drawn line have a break in the middle after maybe a button tap from the user..
Here is some minimally reproduced code..
import SwiftUI
struct ContentView: View {
#State private var path = Path()
#State private var horizontalLines = 0
var body: some View {
GeometryReader { geo in
VStack {
Draw(path: $path)
.stroke()
.clipped()
.padding()
HorizontalLine(path: $path, horizontalLines: $horizontalLines, rect: geo.frame(in: .local))
}
}
}
private func addLine() {
path.addEllipse(in: path.boundingRect)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct Draw: Shape {
#Binding var path: Path
func path(in rect: CGRect) -> Path {
var path = path
path.addRect(rect)
return path
}
}
struct HorizontalLine: View {
#Binding var path: Path
#Binding var horizontalLines: Int
let rect: CGRect
var verticalOffset: CGFloat {
let height = rect.size.height
let tenths = height / 10
return tenths * CGFloat(horizontalLines)
}
var body: some View {
Button("Add Horizontal Lines") {
path = addHLine()
}
}
private func addHLine() -> Path {
var path = path
path.move(to: CGPoint(x: rect.minX + 50, y: verticalOffset))
path.addLine(to: CGPoint(x: rect.maxX - 75 , y: verticalOffset))
self.horizontalLines += 1
return path
}
}
So far I've seen the following technique for stopping an animation, but what I'm looking for here is that the rotating view stops at the angle it was at the moment and not return to 0.
struct DemoView: View {
#State private var isRotating: Bool = false
var foreverAnimation: Animation {
Animation.linear(duration: 1.8)
.repeatForever(autoreverses: false)
}
var body: some View {
Button(action: {
self.isRotating.toggle()
}, label: {
Text("🐰").font(.largeTitle)
.rotationEffect(Angle(degrees: isRotating ? 360 : 0))
.animation(isRotating ? foreverAnimation : .linear(duration: 0))
})
}
}
It seems having the rotation angle to be either 360 or 0 doesn't let me freeze it at an intermediate angle (and eventually resume from there). Any ideas?
Pausing and resuming the animation in SwiftUI is really easy and you're doing it right by determining the animation type in the withAnimation block.
The thing you're missing is two additional pieces of information:
What is the value at which the animation was paused?
What is the value to which the animation should proceed while resumed?
These two pieces are crucial because, remember, SwiftUI views are just ways of expressing what you want your UI to look like, they are only declarations. They are not keeping the current state of UI unless you yourself provide the update mechanism. So the state of the UI (like the current rotation angle) is not saved in them automatically.
While you could introduce the timer and compute the angle values yourself in a discreet steps of X milliseconds, there is no need for that. I'd suggest rather letting the system compute the angle value in a way it feels appropriate.
The only thing you need is to be notified about that value so that you can store it and use for setting right angle after pause and computing the right target angle for resume. There are few ways to do that and I really encourage you to read the 3-part intro to SwiftUI animations at https://swiftui-lab.com/swiftui-animations-part1/.
One approach you can take is using the GeometryEffect. It allows you to specify transform and rotation is one of the basic transforms, so it fits perfectly. It also adheres to the Animatable protocol so we can easily participate in the animation system of SwiftUI.
The important part is to let the view know what is the current rotation state so that it know what angle should we stay on pause and what angle should we go to when resuming. This can be done with a simple binding that is used EXCLUSIVELY for reporting the intermediate values from the GeometryEffect to the view.
The sample code showing the working solution:
import SwiftUI
struct PausableRotation: GeometryEffect {
// this binding is used to inform the view about the current, system-computed angle value
#Binding var currentAngle: CGFloat
private var currentAngleValue: CGFloat = 0.0
// this tells the system what property should it interpolate and update with the intermediate values it computed
var animatableData: CGFloat {
get { currentAngleValue }
set { currentAngleValue = newValue }
}
init(desiredAngle: CGFloat, currentAngle: Binding<CGFloat>) {
self.currentAngleValue = desiredAngle
self._currentAngle = currentAngle
}
// this is the transform that defines the rotation
func effectValue(size: CGSize) -> ProjectionTransform {
// this is the heart of the solution:
// reporting the current (system-computed) angle value back to the view
//
// thanks to that the view knows the pause position of the animation
// and where to start when the animation resumes
//
// notice that reporting MUST be done in the dispatch main async block to avoid modifying state during view update
// (because currentAngle is a view state and each change on it will cause the update pass in the SwiftUI)
DispatchQueue.main.async {
self.currentAngle = currentAngleValue
}
// here I compute the transform itself
let xOffset = size.width / 2
let yOffset = size.height / 2
let transform = CGAffineTransform(translationX: xOffset, y: yOffset)
.rotated(by: currentAngleValue)
.translatedBy(x: -xOffset, y: -yOffset)
return ProjectionTransform(transform)
}
}
struct DemoView: View {
#State private var isRotating: Bool = false
// this state keeps the final value of angle (aka value when animation finishes)
#State private var desiredAngle: CGFloat = 0.0
// this state keeps the current, intermediate value of angle (reported to the view by the GeometryEffect)
#State private var currentAngle: CGFloat = 0.0
var foreverAnimation: Animation {
Animation.linear(duration: 1.8)
.repeatForever(autoreverses: false)
}
var body: some View {
Button(action: {
self.isRotating.toggle()
// normalize the angle so that we're not in the tens or hundreds of radians
let startAngle = currentAngle.truncatingRemainder(dividingBy: CGFloat.pi * 2)
// if rotating, the final value should be one full circle furter
// if not rotating, the final value is just the current value
let angleDelta = isRotating ? CGFloat.pi * 2 : 0.0
withAnimation(isRotating ? foreverAnimation : .linear(duration: 0)) {
self.desiredAngle = startAngle + angleDelta
}
}, label: {
Text("🐰")
.font(.largeTitle)
.modifier(PausableRotation(desiredAngle: desiredAngle, currentAngle: $currentAngle))
})
}
}
Here's my idea. It may not be the smart way.
If you want to stop at an intermediate angle, there are ways to use counting and timer.
struct DemoView: View {
#State private var degree: Double = 0
#State private var amountOfIncrease: Double = 0
#State private var isRotating: Bool = false
let timer = Timer.publish(every: 1.8 / 360, on: .main, in: .common).autoconnect()
var body: some View {
Button(action: {
self.isRotating.toggle()
self.amountOfIncrease = self.isRotating ? 1 : 0
}) {
Text("🐰").font(.largeTitle)
.rotationEffect(Angle(degrees: self.degree))
}
.onReceive(self.timer) { _ in
self.degree += self.amountOfIncrease
self.degree = self.degree.truncatingRemainder(dividingBy: 360)
}
}
}
TLDR; Use a Timer!
I tried a bunch of solutions and this is what worked for me the best!
struct RotatingView: View {
/// Use a binding so parent views can control the rotation
#Binding var isLoading: Bool
/// The number of degrees the view is rotated by
#State private var degrees: CGFloat = 0.0
/// Used to terminate the timer
#State private var disposeBag = Set<AnyCancellable>()
var body: some View {
Image(systemName: "arrow.2.circlepath")
.rotationEffect(.degrees(degrees))
.onChange(of: isLoading) { newValue in
if newValue {
startTimer()
} else {
stopTimer()
}
}
}
private func startTimer() {
Timer
.publish(every: 0.1, on: .main, in: .common)
.autoconnect()
.receive(on: DispatchQueue.main)
.sink { _ in
withAnimation {
degrees += 20 /// More degrees, faster spin
}
}
.store(in: &disposeBag)
}
private func stopTimer() {
withAnimation {
/// Snap to the nearest 90 degree angle
degrees += (90 - (degrees.truncatingRemainder(dividingBy: 90)))
}
/// This will stop the timer
disposeBag.removeAll()
}
}
Using Your View
struct ParentView: View {
#State isLoading: Bool = false
var body: some View {
RotatingView(isLoading: $isLoading)
}
}