Anchor point in SwiftUI - ios

I'm playing with SwiftUI animations. I wanted to make a square bigger modifying its frame with an animation this way:
struct ContentView: View {
let squareWidth = CGFloat(100)
let squareHeight = CGFloat(100)
#State private var bigger = false
var body: some View {
VStack {
Color.green
.frame(width: bigger ? self.squareWidth * 2 : self.squareWidth, height: self.squareHeight)
.animation(.default)
Button(action: {
self.bigger.toggle()
}) {
Text("Click me!")
}
}
}
}
The animation happens from the centre of the View, that is the anchor point (0.5, 0.5). Is there a way to change this anchor point? I found out that I can use scaleEffect on the view specifying an anchor:
struct ContentView: View {
let squareWidth = CGFloat(100)
let squareHeight = CGFloat(100)
#State private var bigger = false
var body: some View {
VStack {
Color.green
.frame(width: self.squareWidth, height: self.squareHeight)
.scaleEffect(x: bigger ? 2 : 1, y: 1, anchor: .leading)
.animation(.default)
Button(action: {
self.bigger.toggle()
}) {
Text("Click me!")
}
}
}
}
But it doesn't seem exactly the same. If I needed to apply several effects I should specify the anchor point for each of them, instead of specifying the anchor point on the view just one time. On UIKit I could write:
var myView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
myView.layer.anchorPoint = CGPoint(x: 0, y: 0.5) //that is, the .leading of SwiftUI

It is possible, but your approach must change.
There is an implied, built-in centring of views in SwiftUI, which can be confusing.
The parent view is the one placing the content in the centre, recalculating the position of its subviews each time their frames change.
Embedding the VStack in a HStack and adding borders to both clearly shows this behaviour.
Also, note the Spacer(), which pushes the view to the left.
HStack {
VStack {
Color.green
.frame(width: bigger ? self.squareWidth * 2 : self.squareWidth)
.frame(height: self.squareHeight)
.animation(.default)
Button("Click me!") { self.bigger.toggle() }
}.border(Color.black)
Spacer()
}.border(Color.red)
Using the .layer.anchorPoint in UIKit should have the same effect as using scaleEffect/offset/rotationEffect in SwiftUI, as it should affect the origin of the underlying geometry/texture which is being rendered by the GPU.
If you have a complex view (composed of multiple subviews) that needs the same scale effect, you can group them using a Group { } and use .scaleEffect on it.

Related

Scaled view unexpectedly moving up and down when pushed from navigation view

I have a view that should scale in and out, starting immediately when the view is shown and repeating forever. However, I find that it's actually animating up and down as well as scaling much like in this post when it's pushed from a navigation view:
struct PlaceholderView: View {
#State private var isAnimating = false
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.isAnimating ? 0.8 : 1)
.animation(Animation.easeInOut(duration: 1).repeatForever())
.onAppear {
self.isAnimating = true
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}
struct SettingsView: View {
#State private var showPlaceholder = false
var body: some View {
NavigationView {
ZStack {
Button(
action: {
showPlaceholder = true
}, label: {
Text("Go to placeholder")
}
)
NavigationLink(
destination: PlaceholderView(),
isActive: $showPlaceholder
) {
EmptyView()
}
.hidden()
}
}
.navigationViewStyle(.stack)
}
}
Why is this and how can I stop this from happening?
UPDATE:
Wrapping self.isAnimating = true in DispatchQueue.main.async {} fixes the issue, but I don't understand why...
I can reproduce the problem, but I wasn't able to reproduce your DispatchQueue.main.async {} fix.
Here is how I fixed it:
Animation is made by comparing a before state and and after state and animating between them. With an implicit animation, the before state is the position of the circle before it is added to the navigation view. By switching to an explicit animation and setting your before state after the view appears, the move animation is avoided and you only get the scaling:
Replace isAnimating with scale. In onAppear {}, first establish the before scale of 0.8, then animate the change to scale 1:
struct PlaceholderView: View {
// To work correctly, this initial value should be different
// than the before value set in .onAppear {}.
#State private var scale = 1.0
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.scale)
.onAppear {
self.scale = 0.8 // before
withAnimation(.easeInOut(duration: 1).repeatForever()) {
self.scale = 1 // after
}
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}

SwiftUI: Infinite Scrolling Horizontal ScrollView

I need to create an infinite horizontal scrolling with ScrollView to scroll a panorama image. So I would like to make this scrolling infinite from both sides, is there any possible way to make it? I have searched maybe using ScrollViewReader can achieve this but no luck.
ScrollView(.horizontal, showsIndicators: false) {
Image("panorama")
.resizable()
.scaledToFill()
}
Actually for such prepared image it is enough to load it only once, and we need only to Image presenters for it (note: they do not copy memory, but use the same loaded image). Everything else is just about layout on the fly moving current of-screen item depending on drag direction to left or to the right of current one.
Tested with Xcode 13.4 / iOS 15.5
Main part of code:
struct PanoramaView: View {
let image: UIImage
private struct Item: Identifiable, Equatable {
let id = UUID()
var pos: CGFloat!
}
#State private var items = [Item(), Item()]
// ...
var body: some View {
GeometryReader { gp in
let centerY = gp.size.height / 2
ForEach($items) { $item in
Image(uiImage: image)
.resizable().aspectRatio(contentMode: .fill)
.position(x: item.pos ?? 0, y: centerY)
.offset(x: dragOffset)
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewSizeKey.self,
value: $0.frame(in: .local).size)
})
.onPreferenceChange(ViewSizeKey.self) {
setupLayout($0)
}
.contentShape(Rectangle())
.gesture(scrollGesture)
}
and usage
let panorama = UIImage(contentsOfFile: Bundle.main.path(forResource: "panorama", ofType: "jpeg")!)!
var body: some View {
PanoramaView(image: panorama)
.frame(height: 300) // to demo of dynamic internal layout
}
Complete test code is here
You can put 3 identical panorama images next to each other (to be able to scroll over the edges) and add a custom DragGesture, that basically jumps back to the relative position of the middle image.
This image is a bad example, obviously it will work better with a real 360° image ;)
EDIT:
now with predicted end location + animation and 5 images.
struct ContentView: View {
#State private var dragOffset = CGFloat.zero
#State private var offset = CGFloat.zero
let imageWidth: CGFloat = 500
var body: some View {
HStack(spacing: 0) {
ForEach(0..<5) { _ in
Image("image")
.resizable()
.frame(width: imageWidth)
}
}
.offset(x: offset + dragOffset)
.gesture(
DragGesture()
.onChanged({ value in
dragOffset = value.translation.width
})
.onEnded({ value in
withAnimation(.easeOut) {
dragOffset = value.predictedEndTranslation.width
}
offset = (offset + dragOffset).remainder(dividingBy: imageWidth)
dragOffset = 0
})
)
}
}

decrease the tappable area of a Rectangle SwiftUI

How is it possible to decrease the area where the user can tap on a Rectangle with rounded Corners. When im tapping at the Position of the image, then there will be a reaction although i dont want it.
struct ContentView: View {
#State var x = false
var body: some View {
Rectangle()
.frame(width: 100, height: 100, alignment: .center)
.cornerRadius(x ? 10.0 : 35.0).animation(Animation.easeOut(duration: 0.4).delay(0.3))
.foregroundColor(x ? Color.blue : Color.red).animation(Animation.easeIn(duration: 0.1).delay(0))
.onTapGesture {
x = !x
}
}
}

swiftUI transitions: Scale from some frame - like iOS Homescreen is doing when opening an App

How do I get the effect in a translation, that iOS home screen does when opening an App: It scales the App to fullscreen, starting from the App-icon on the home screen?
In my code, I have the icon-frame (CGRect) with position, width and height and I have the final frame. Is there a way to (probably combine some) transitions to get a scaling from the icon-frame to the final frame?
I get somewhat similar with:
view.transition(AnyTransition.scale(scale: 0, anchor: UnitPoint.trailing))
Which scales from zero to the original size, starting from trailing center position.
It's only close as:
it scales from zero instead of the size of the original icon
it starts from a fixed point (trailing, center) but I'd like to let it start from where the icon is.
Just to be sure: It must be a transition as the view is newly created and dropped. I tried with keeping the view and just change its opacity to show/hide it. With many other problems like not getting the reverse animation, when the view disappears.
Here is a demo of idea how such effect could be done with combined transitions... (positions and sizes are hardcoded for demo simplicity - they can be read with geometry reader, alignment guides or anchor preferences, and actually do not affect the transition usage idea, also animation can be configured)
Demo:
struct TestRisingView: View {
let screen = UIScreen.main.bounds
#State var showingView = false
#State var btFrame: CGRect = .zero
var body: some View {
GeometryReader { g in
ZStack {
Rectangle().fill(Color.clear)
self.activatingButton(frame: CGRect(x: 80, y: 30, width: 60, height: 40))
self.activatingButton(frame: CGRect(x: self.screen.maxX - 80, y: 30, width: 60, height: 40))
self.activatingButton(frame: CGRect(x: self.screen.maxX - 80, y: self.screen.maxY - 60, width: 60, height: 40))
self.activatingButton(frame: CGRect(x: 80, y: self.screen.maxY - 60, width: 60, height: 40))
if self.showingView {
self.topView
.zIndex(1)
.transition(
AnyTransition.scale(scale: 0.12).combined(with:
AnyTransition.offset(x: self.btFrame.origin.x - g.size.width/2.0,
y: self.btFrame.origin.y - g.size.height/2.0))
)
}
}
}
}
func activatingButton(frame: CGRect) -> some View {
Button(action: {
withAnimation {
self.btFrame = frame
self.showingView.toggle()
}
}) {
Text("Tap")
.padding()
.background(Color.yellow)
}
.position(frame.origin)
}
var topView: some View {
Rectangle()
.fill(Color.green)
.frame(width: 300, height: 400)
}
}
The above sample code should give you an idea on scaling an image
struct ImageCustomScaling: View {
// image to be scaled
var scaleImage: Image
// scale ratio
var scaleTo: Double
#State private var start = false
var body: some View {
VStack {
scaleImage
.font(.title)
.scaleEffect(self.start ? CGFloat(scaleTo) : 1)
.opacity(self.start ? 1 : 0)
.animation(Animation.interpolatingSpring(stiffness: 25, damping: 5, initialVelocity: 10).delay(0.9))
}
.foregroundColor(.blue)
.onAppear {
self.start = true
}
}
}
Above can be called by something like below
ImageCustomScaling(scaleImage: Image(systemName: "cloud.fill"), scaleTo: 5 )
Added animation modifier to give visual cue. You can change it to meet your needs.
Now in 2021 we finally got an answer from Apple. matchedGeometryEffect solves this problem elegantly and easily.
Here is a little screenshot of what it does:
And here is the code for this demo:
struct Stackoverflow: View {
#Namespace var namespace
#State private var shown: Bool = false
var body: some View {
VStack {
Button {
shown.toggle()
} label: {
Text("Tap Me!\nI'm a red square.")
.multilineTextAlignment(.center)
}
.background(
Color.red
.matchedGeometryEffect(id: "1", in: namespace, properties: .frame))
Spacer()
if shown {
Color.clear
.aspectRatio(contentMode: .fit)
.matchedGeometryEffect(id: "1", in: namespace, properties: .size)
.border(Color.gray)
}
Spacer()
}
.animation(.easeInOut(duration: 1))
}
}
struct Stackoverflow_Previews: PreviewProvider {
static var previews: some View {
Stackoverflow()
}
}
Check here on swift-lab.com, for more information and explanation on how to use matchedGeometryEffect.

Change Button Anchor Point SwiftUI

I'm attempting to alter the anchor point for what determines the center of a Button. The following code puts the button at the top left corner of the frame.
Button(action: {
print(self.note)
}) {
Text(note)
}
.position(x: 0.0, y: 0.0)
If I use .offset instead, then it will work. I would like it to be centered within it's frame though. Is there a way to change the anchor point?
You may need to turn on the frame of the parent container, so that you can use frame alignment.
var body: some View{
GeometryReader{ p in
VStack{
Button(action: {
}) {
Text("note")
}
}.frame(width: p.size.width, height: p.size.height, alignment: .topLeading)
}
}
Here is another rough but faster version with AlignmentGuide.
var body: some View{
VStack(alignment: .leading){
Text("")
Button(action: {
}) {
Text("simple version button")
}.background(Color.red).alignmentGuide(.leading) { v in
return -v[.trailing]
}}.position()
}
Hope you can have a better answer.

Resources