Reload SwiftUI view from within it - view

I have some text inside a View and I want this text to change it's position on timer. I have the following:
struct AlphabetView: View {
#State var timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var refreshMe: Bool = false // this is a hack to refresh the view
var relativePositionX: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var relativePositionY: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var body: some View {
GeometryReader { geometry in
Text("Hello World!")
.position(x: geometry.size.width * self.relativePositionX, y: geometry.size.height * self.relativePositionY)
.onReceive(timer) { _ in
// a hack to refresh the view
self.refreshMe = !self.refreshMe
}
}
}
}
I suppose that the View should be reloaded every time self.refreshMe changes, because it is #State. But what I see is that the text position changes only when the parent view of the view in question is reloaded. What am I doing wrong (except hacking around)? Is there a better way to do this?

Instead of hack just update position directly.
Here is corrected variant. Tested with Xcode 12 / iOS 14.
struct AlphabetView: View {
let timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var point: CGPoint = .zero
var relativePositionX: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var relativePositionY: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var body: some View {
GeometryReader { geometry in
Text("Hello World!")
.position(self.point)
.onReceive(timer) { _ in
self.point = CGPoint(x: geometry.size.width * self.relativePositionX, y: geometry.size.height * self.relativePositionY)
}
}
}
}

Related

Tracking scroll position in a List SwiftUI

So in the past I have been tracking the scroll position using a scroll view but I've fallen into a situation where I need to track the position using a List. I am using a List because I want some of the built in real estate to create my views such as the default List styles.
I can get the value using PreferenceKeys, but the issue is when I scroll to far upwards, the PreferenceKey value will default back to its position 0, breaking my show shy header view logic.
This is the TrackableListView code
struct TrackableListView<Content: View>: View {
let offsetChanged: (CGPoint) -> Void
let content: Content
init(offsetChanged: #escaping (CGPoint) -> Void = { _ in }, #ViewBuilder content: () -> Content) {
self.offsetChanged = offsetChanged
self.content = content()
}
var body: some View {
List {
GeometryReader { geometry in
Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("ListView")).origin)
}
.frame(width: 0, height: 0)
content
.offset(y: -10)
}
.coordinateSpace(name: "ListView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
And this is my ContentView:
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
#State private var offsetPositionValue: CGFloat = 0
#State private var isShyHeaderVisible = false
var body: some View {
NavigationView {
ZStack {
TrackableListView { offset in
withAnimation {
contentOffset = offset.y
}
} content: {
Text("\(contentOffset)")
}
.overlay(
ZStack {
HStack {
Text("Total points")
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("20,000 pts")
.foregroundColor(.white)
.padding(.leading, 50)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width)
.background(Color.green)
.offset(y: contentOffset < 50 ? 0 : -5)
.opacity(contentOffset < 50 ? 1 : 0)
.transition(.move(edge: .top))
}
.frame(maxHeight: .infinity, alignment: .top)
)
}
.navigationTitle("Hello")
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarTitleDisplayMode(.inline)
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
}
}
}
The issue was that Other Views in my hierarchy (probably introduced by SwiftUI) could be sending the default value, which is why you start getting zero sometimes.
What I needed was a way to determine when to use or forward the new value, versus when to ignore it.
To do this I had to make my value optional GCPoint, with a nil default value, then in your reduce method when using PreferenceKeys you have to do:
if let nextValue = nextValue() {
value = nextValue
}
Then make sure your CGPoint is an optional value.

How to trigger SwiftUI animation via change of non-State variable

It's easy to have an animation begin when a view appears, by using .onAppear(). But I'd like to perform a repeating animation whenever one of the view's non-State variables changes. An example:
Here is a view that I would like to "throb" whenever its throbbing parameter, set externally, is true:
struct MyCircle: View {
var throbbing: Bool
#State var scale = 1.0
var body: some View {
Circle()
.frame(width: 100 * scale, height: 100 * scale)
.foregroundColor(.blue)
.animation(.easeInOut.repeatForever(), value: scale)
.onAppear { scale = 1.2 }
}
}
Currently the code begins throbbing immediately, regardless of the throbbing variable.
But imagine this scenario:
struct ContentView: View {
#State var throb: Bool = false
var body: some View {
VStack {
Button("Throb: \(throb ? "ON" : "OFF")") { throb.toggle() }
MyCircle(throbbing: throb)
}
}
}
It looks like this:
Any ideas how I can modify MyCircle so that the throbbing starts when the button is tapped and ends when it is tapped again?
You can use onChange to watch throbbing and then assign an animation. If true, add a repeating animation, and if false, just animate back to the original scale size:
struct MyCircle: View {
var throbbing: Bool
#State private var scale : CGFloat = 0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.scaleEffect(1 + scale)
.foregroundColor(.blue)
.onChange(of: throbbing) { newValue in
if newValue {
withAnimation(.easeInOut.repeatForever()) {
scale = 0.2
}
} else {
withAnimation {
scale = 0
}
}
}
}
}
There're two interesting things that i've just found when trying to achieve yours target.
Animation will be added when the value changed
Animation that added to the view cannot be removed, and we need to drop the animated view from the view hiearachy by remove its id, new view will be created with zero animations.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var throbbling = false
#State var circleId = UUID()
var body: some View {
VStack {
Toggle("Throbbling", isOn: $throbbling)
circle.id(circleId)
.scaleEffect(throbbling ? 1.2 : 1.0)
.animation(.easeInOut.repeatForever(), value: throbbling)
.onChange(of: throbbling) { newValue in
if newValue == false {
circleId = UUID()
}
}
}.padding()
}
#ViewBuilder
var circle: some View {
Circle().fill(.green).frame(width: 100, height: 100)
}
}
PlaygroundPage.current.setLiveView(ContentView())

How to change color of a shape after a time x?

Sorry if it's a simple fix, I am a still bit new to swift/swiftui
I have been trying to accomplish a change of color after a second.
struct ContentView: View {
#State private var hasTimeElapsed = false
var colorCircle: [Color] = [.red, .green]
var body: some View {
Circle()
.fill(self.colorCircle.randomElement()!)
.frame(width: 100,
height: 100)
.onAppear(perform: delayCircle)
}
private func delayCircle() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.hasTimeElapsed = true
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
There's no need to use the hasTimeElapsed variable. Instead you can add a #State variable to store your current colour and set it in delayCircle():
struct ContentView: View {
var colorCircle: [Color] = [.red, .green]
#State var currentColor = Color.red
var body: some View {
Circle()
.fill(currentColor)
.frame(width: 100,
height: 100)
.onAppear(perform: delayCircle)
}
private func delayCircle() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.currentColor = self.colorCircle.randomElement()!
}
}
}
If you want to start with a random colour (instead of #State var currentColor = Color.red) you can set it in init:
#State var currentColor: Color
init() {
_currentColor = .init(initialValue: self.colorCircle.randomElement()!)
}
UPDATE due to OP's comment
If you want to set your circle colour in a loop you can use a Timer:
struct ContentView: View {
var colorCircle: [Color] = [.red, .green]
#State var currentColor: Color = .red
// firing every 1 second
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Circle()
.fill(currentColor)
.frame(width: 100,
height: 100)
Button("Stop timer") {
self.timer.upstream.connect().cancel() // <- stop the timer
}
}
.onReceive(timer) { _ in // <- when the timer fires perfom some action (here `randomizeCircleColor()`)
self.randomizeCircleColor()
}
}
private func randomizeCircleColor() {
self.currentColor = self.colorCircle.randomElement()!
}
}

Animate the width of a Rectangle over time

In SwiftUI on WatchOS, how can I animate the width of a Rectangle (or any View for that matter) so that it starts at a certain value and over a specified time animates to a different value?
Specifically, I want to animate a Rectangle to indicate the time left to the next full minute or the next 30 seconds after a minute.
All the examples I've seen are based on Timer.scheduledTimer firing at relatively high speed and setting a #State variable, but my understanding is that especially on WatchOS this should be avoided. Is there a better way?
This is the timer/state based code I have but I feel like there should be a more efficient way:
import SwiftUI
func percentage() -> CGFloat {
1 - CGFloat(fmod(Date().timeIntervalSince1970, 30) / 30)
}
struct ContentView: View {
#State var ratio: CGFloat = percentage()
let timer = Timer.publish(every: 1 / 60, on:.main, in:.common).autoconnect()
var body: some View {
GeometryReader { geometry in
ZStack {
Rectangle()
.foregroundColor(Color.gray)
.frame(width:geometry.size.width, height:5)
HStack {
Rectangle()
.foregroundColor(Color.red)
.frame(width:geometry.size.width * self.ratio, height:5)
Spacer()
}
}
}.onReceive(self.timer) { _ in
self.ratio = percentage()
}
}
}
I think a "more efficient way" to use animation:
struct AnimationRectangle: View {
struct AnimationRectangle: View {
#State private var percentage: CGFloat = 0.0
// count, how much time left to nearest 30 seconds
#State private var animationDuration = 30 - Double(fmod(Date().timeIntervalSince1970, 30))
private var repeatedAnimationFor30Seconds: Animation {
return Animation.easeInOut(duration: 30)
.repeatForever(autoreverses: false)
}
var body: some View {
VStack {
// just showing duration of current animation
Text("\(self.animationDuration)")
ZStack {
Rectangle()
.foregroundColor(.gray)
GeometryReader { geometry in
HStack {
Rectangle()
.foregroundColor(.green)
.frame(width: geometry.size.width * self.percentage)
Spacer()
}
}
}
.frame(height: 5)
.onAppear() {
// first animation without repeating
withAnimation(Animation.easeInOut(duration: self.animationDuration)) {
self.percentage = 1.0
}
// other repeated animations
DispatchQueue.main.asyncAfter(deadline: .now() + self.animationDuration) {
self.percentage = 0.0
self.animationDuration = 30.0
withAnimation(self.repeatedAnimationFor30Seconds) {
self.percentage = 1.0
}
}
}
}
}
}
struct AnimationRectangle_Previews: PreviewProvider {
static var previews: some View {
AnimationRectangle()
}
}

How do I render a SwiftUI View that is not at the root hierarchy as a UIImage?

Suppose I have a simple SwiftUI View that is not the ContentView such as this:
struct Test: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
How can I render this view as a UIImage?
I've looked into solutions such as :
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
But it seems that solutions like that only work on UIView, not a SwiftUI View.
Here is the approach that works for me, as I needed to get image exactly sized as it is when placed alongside others. Hope it would be helpful for some else.
Demo: above divider is SwiftUI rendered, below is image (in border to show size)
Update: re-tested with Xcode 13.4 / iOS 15.5
Test module in project is here
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
UIApplication.shared.windows.first?.rootViewController?.view.addSubview(controller.view)
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
// [!!] Uncomment to clip resulting image
// rendererContext.cgContext.addPath(
// UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath)
// rendererContext.cgContext.clip()
// As commented by #MaxIsom below in some cases might be needed
// to make this asynchronously, so uncomment below DispatchQueue
// if you'd same met crash
// DispatchQueue.main.async {
layer.render(in: rendererContext.cgContext)
// }
}
}
}
// TESTING
struct TestableView: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
struct TestBackgroundRendering: View {
var body: some View {
VStack {
TestableView()
Divider()
Image(uiImage: render())
.border(Color.black)
}
}
private func render() -> UIImage {
TestableView().asImage()
}
}
Solution of Asperi works, but if you need image without white background you have to add this line:
controller.view.backgroundColor = .clear
And your View extension will be:
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
controller.view.backgroundColor = .clear
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}

Resources