How to pinch and scroll an image in SwiftUI? - image

I want to add an image viewer in my application but I have trouble implementing it. I want to display an image and allow the user to pinch and scroll in the image to examine it in details. Based on what I've gathered from multiple Internet posts, I have something kinda working, but not exactly. Here is my code:
import SwiftUI
struct ContentView: View {
#State var currentScale: CGFloat = 1.0
#State var previousScale: CGFloat = 1.0
#State var currentOffset = CGSize.zero
#State var previousOffset = CGSize.zero
var body: some View {
ZStack {
Image("bulldog2")
.resizable()
.edgesIgnoringSafeArea(.all)
.aspectRatio(contentMode: .fit)
.offset(x: self.currentOffset.width, y: self.currentOffset.height)
.scaleEffect(self.currentScale)
.gesture(DragGesture()
.onChanged { value in
let deltaX = value.translation.width - self.previousOffset.width
let deltaY = value.translation.height - self.previousOffset.height
self.previousOffset.width = value.translation.width
self.previousOffset.height = value.translation.height
self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
.onEnded { value in self.previousOffset = CGSize.zero })
.gesture(MagnificationGesture()
.onChanged { value in
let delta = value / self.previousScale
self.previousScale = value
self.currentScale = self.currentScale * delta
}
.onEnded { value in self.previousScale = 1.0 })
VStack {
Spacer()
HStack {
Text("Menu 1").padding().background(Color.white).cornerRadius(30).padding()
Spacer()
Text("Menu 2").padding().background(Color.white).cornerRadius(30).padding()
}
}
}
}
}
The initial view looks like this:
The first problem I have is that I can move the image too far in the way that I can see outside of the image. This can cause the image not to be not visible in the application anymore if it moved too far.
The second problem, which is not a big one, is that I can scale down the image but it becomes too small compared to the view. I want to constraint it so that "fit" would be its smallest size. Is there a better way than constraining self.currentScale and self.previousScale?
The third problem is that if I change the image to fill the space, the bottom menu gets larger that the phone's screen.
I'm not an iOS developer and there is probably a much better way to implement this feature. Thanks for you help.

I can answer 2 of 3 questions. I can't repeat the third one.
you can use GeometryReader and use it's frame and size to make some constraints (I'll show it in example below);
maybe the most simplest and better way is just to use max function, like .scaleEffect(max(self.currentScale, 1.0)).
Here is changed example:
struct BulldogImageViewerView: View {
#State var currentScale: CGFloat = 1.0
#State var previousScale: CGFloat = 1.0
#State var currentOffset = CGSize.zero
#State var previousOffset = CGSize.zero
var body: some View {
ZStack {
GeometryReader { geometry in // here you'll have size and frame
Image("bulldog")
.resizable()
.edgesIgnoringSafeArea(.all)
.aspectRatio(contentMode: .fit)
.offset(x: self.currentOffset.width, y: self.currentOffset.height)
.scaleEffect(max(self.currentScale, 1.0)) // the second question
.gesture(DragGesture()
.onChanged { value in
let deltaX = value.translation.width - self.previousOffset.width
let deltaY = value.translation.height - self.previousOffset.height
self.previousOffset.width = value.translation.width
self.previousOffset.height = value.translation.height
let newOffsetWidth = self.currentOffset.width + deltaX / self.currentScale
// question 1: how to add horizontal constraint (but you need to think about scale)
if newOffsetWidth <= geometry.size.width - 150.0 && newOffsetWidth > -150.0 {
self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
}
self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
.onEnded { value in self.previousOffset = CGSize.zero })
.gesture(MagnificationGesture()
.onChanged { value in
let delta = value / self.previousScale
self.previousScale = value
self.currentScale = self.currentScale * delta
}
.onEnded { value in self.previousScale = 1.0 })
}
VStack {
Spacer()
HStack {
Text("Menu 1").padding().cornerRadius(30).background(Color.blue).padding()
Spacer()
Text("Menu 2").padding().cornerRadius(30).background(Color.blue).padding()
}
}
}
}
}
with this code I achieved:
user can't move picture away from the screen in horizontal direction;
user can't make picture smaller;

Related

SwiftUI transition with opacity not showing

I'm still new to SwiftUI. I'm trying to get each change of an image to start out at opacity 0.0 (fully transparent), then increase to opacity 1.0 (fully opaque)
I expected I could achieve this using the .opacity transition. .opacity is described as a "transition from transparent to opaque on insertion", so my assumption is that by stating "withAnimation" in my Button action, I'd trigger the Image to be re-rendered, and the transition would occur beginning from faded to transparent. Instead I see the same instant appear of the new shape & slow morphing to a new size, no apparent change in .opacity. Code and .gif showing current result, below. I've used UIKit & know I'd set alpha to zero, then UIView.animate to alpha 1.0 over a duration of 1.0, but am unsure how to get the same effect in SwiftUI. Thanks!
struct ContentView: View {
#State var imageName = ""
var imageNames = ["applelogo", "peacesign", "heart", "lightbulb"]
#State var currentImage = -1
var body: some View {
VStack {
Spacer()
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.transition(.opacity)
Spacer()
Button("Press Me") {
currentImage = (currentImage == imageNames.count - 1 ? 0 : currentImage + 1)
withAnimation(.linear(duration: 1.0)) {
imageName = imageNames[currentImage]
}
}
}
}
}
The reason you are not getting the opacity transition is that you are keeping the same view. Even though it is drawing a different image each time, SwiftUI sees Image as the same. the fix is simple: add .id(). For example:
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.transition(.opacity)
// Add the id here
.id(imageName)
Here is the correct approach for this kind of issue:
We should not forgot how transition works!!! Transition modifier simply transmit a view to nothing or nothing to a view (This should be written with golden ink)! in your code there is no transition happening instead update happing.
struct ContentView: View {
#State var imageName1: String? = nil
#State var imageName2: String? = nil
var imageNames: [String] = ["applelogo", "peacesign", "heart", "lightbulb"]
#State var currentImage = -1
var body: some View {
VStack {
Spacer()
ZStack {
if let unwrappedImageName: String = imageName1 {
Image(systemName: unwrappedImageName)
.resizable()
.transition(AnyTransition.opacity)
.animation(nil, value: unwrappedImageName)
.scaledToFit()
}
if let unwrappedImageName: String = imageName2 {
Image(systemName: unwrappedImageName)
.resizable()
.transition(AnyTransition.opacity)
.animation(nil, value: unwrappedImageName)
.scaledToFit()
}
}
.padding()
.animation(.linear, value: [imageName1, imageName2])
Spacer()
Button("Press Me") {
currentImage = (currentImage == imageNames.count - 1 ? 0 : currentImage + 1)
if imageName1 == nil {
imageName2 = nil; imageName1 = imageNames[currentImage]
}
else {
imageName1 = nil; imageName2 = imageNames[currentImage]
}
}
}
}
}

SwiftUI Drag gesture is not ending

I was trying to create a custom View with rotation effects in SwiftUI. Instead of using rotation gestures, I was trying to use the Drag Gesture to rotate the Parent by adding a separate view to the parent. Dragging the child (The Blue Dot) would rotate its parent (ZStack). But I've faced difficulty while dragging the blue dot. The drag gesture is not ending. Can you guys help me to find out what I did wrong?
import SwiftUI
struct EditingHolder: View {
/* For Rotation Circle Drag */
#State private var currDragRect : CGSize = .zero
#State private var prevDragRect : CGSize = .zero
var rectRadius : CGFloat {
let sum : CGFloat = 2*pow(150, 2)
return sqrt(sum/4)
}
var dragRadius : CGFloat {
let height = prevDragRect.height + currDragRect.height
let width = prevDragRect.width + currDragRect.width
let sum = pow(height, 2) + pow(width, 2)
return sqrt(sum/4)
}
var rotateAngle : CGFloat{
let angle = asin(dragRadius/rectRadius)
print("🍄 Angle Produced = ", angle)
return angle
}
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
currDragRect = value.translation
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
prevDragRect.height += currDragRect.height
prevDragRect.width += currDragRect.width
currDragRect = .zero
})
//************* Views *******************
GeometryReader { geo in
ZStack(alignment: .center) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.position(x: 150 - 3, y: 3)
.gesture(rotateDrag)
}.frame(width:150, height: 150, alignment: .center)
.border(.green, width: 3)
.position(x: geo.size.width/2, y: geo.size.height/2)
.rotationEffect(.radians(rotateAngle))
}
//************* Views *******************
}
}
struct EditingHolder_Previews: PreviewProvider {
static var previews: some View {
EditingHolder()
}
}
To provide high priority to a specific gesture there is a modifier called .highPriorityGesture(). Have a look here for a better explanation How to use gestures in SwiftUI.
I've updated the angle calculations,
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
let difY = center.y - value.location.y
let difX = center.x - value.location.x
//Initial Angle when the drag started..
if deltaAngle == 0{
deltaAngle = atan2(difY, difX)
}else{
angle = atan2(difY, difX) - deltaAngle
}
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
withAnimation {
angle = 0
deltaAngle = 0
}
})
//************* Views *******************
Now to add .highPriorityGesture(rotateDrag) to ZStack.
.onTapGesture() is added to get the center for angle calculation. Tap on the view and then rotate by dragging the blue dot.
Here is the final implementation,
struct EditingHolder: View {
/* For Rotation Circle Drag */
#State private var center : CGPoint = .zero
#State private var angle : CGFloat = 0
#State private var deltaAngle : CGFloat = 0
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
let difY = center.y - value.location.y
let difX = center.x - value.location.x
//Initial Angle when the drag started..
if deltaAngle == 0{
deltaAngle = atan2(difY, difX)
}else{
angle = atan2(difY, difX) - deltaAngle
}
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
withAnimation {
angle = 0
deltaAngle = 0
}
})
//************* Views *******************
GeometryReader { geo in
ZStack(alignment: .center) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.position(x: 150 - 3, y: 3)
.gesture(rotateDrag)
}.frame(width:150, height: 150, alignment: .center)
.border(.green, width: 3)
.position(x: geo.size.width/2, y: geo.size.height/2)
.rotationEffect(Angle(radians: angle))
/* You have make the gesture a high priority */
.highPriorityGesture(rotateDrag)
.onTapGesture {
print("☘️ Center assigned..")
center = CGPoint(x: geo.frame(in: .global).size.width/2, y: geo.frame(in: .global).size.height/2)
}
}
//************* Views *******************
}
}
struct EditingHolder_Previews: PreviewProvider {
static var previews: some View {
EditingHolder()
}
}
The sequence of drag gesture and rotation is important, otherwise SwiftUI looses context of the dragged view (which is changing by drag).
Also you don't need GeometryReader. Here is an example that works in regards to the dragging, the angle calculation needs some more work.
struct ContentView: View {
/* For Rotation Circle Drag */
#State private var currDragRect : CGSize = .zero
#State private var prevDragRect : CGSize = .zero
let rectRadius : CGFloat = 75
var dragRadius : CGFloat {
let height = prevDragRect.height + currDragRect.height
let width = prevDragRect.width + currDragRect.width
let sum = pow(height, 2) + pow(width, 2)
return sqrt(sum/4)
}
var rotateAngle : CGFloat{
let x = min(1, max(-1, dragRadius/rectRadius)) // asin only defined in -1...1
let angle = asin(x)
print("🍄 Angle Produced = ", angle)
return angle
}
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged { value in
print("🏵 Rotate Circle Drag Started ...")
currDragRect = value.translation
}
.onEnded { _ in
print("🏵 Rotate Circle Drag Ended ✅")
prevDragRect.height += currDragRect.height
prevDragRect.width += currDragRect.width
currDragRect = .zero
}
//************* Views *******************
ZStack(alignment: .topTrailing) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
.border(.green, width: 3)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.offset(x: 8, y: -8)
}
.rotationEffect(.radians(rotateAngle)) // rotation here
.gesture(rotateDrag) // drag here
.frame(width:150, height: 150, alignment: .center)
//************* Views *******************
}
}

SwiftUI MagnificationGesture NOT WORKING PROPERLY on Mac

I have a problem with the MagnificationGesture in SwiftUI on the mac. I am writing a Mac app and I want to scale a view. When I run the program, it works fine for a couple of times and then the onChanged closure does not get executed anymore. I am afraid this is a bug... (or do I completely missunderstand something?). I actually found a very recent question on reddit, where someone has the exact same issue: https://www.reddit.com/r/SwiftUI/comments/sd43rk/im_having_an_issue_with_the_magnificationgesture/
I could reproduce the problem in a very simple view:
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.gesture(MagnificationGesture()
.onChanged({ value in
print(value)
}))
}
}
I really hope, there is a solution to this...
Frederik :)
I accidentally found a fix. I'm listening for both drag and magnification. Drag changes the x/y offsets of an image. When magnification stopped responding, dragging the image slightly would make magnification work again. So, at the end of each magnification event, I add a small offset. Seems to work.
Image(nsImage: nsImage)
.resizable()
.frame(width: width, height: height)
.scaleEffect(finalAmount + currentAmount)
.offset(x: offsetX, y:offsetY)
.gesture(
DragGesture()
.onChanged { value in
offsetY = value.translation.height + offsetYBuffer
offsetX = value.translation.width + offsetXBuffer
}
.onEnded { value in
offsetXBuffer = value.translation.width + offsetXBuffer
offsetYBuffer = value.translation.height + offsetYBuffer
}
).gesture(
MagnificationGesture()
.onChanged { value in
currentAmount = value - 1
}
.onEnded { value in
finalAmount += currentAmount
currentAmount = 0
offsetY += 0.1 //this seems to fix it
}
)
this is my slightly adapted code – for me it works fine, also after 30 times (macOS 12.2beta, Xcode 13.2.1)
struct ContentView: View {
#State private var scale: CGFloat = 1
var body: some View {
Text("Hello, world!")
.scaleEffect(scale)
.padding()
.frame(width: 400, height: 400)
.contentShape(Rectangle())
.gesture(MagnificationGesture()
.onChanged({ value in
scale = value
print(value)
}))
}
}

SwiftUI Animation Circle with Colors

My problem is simple I think but I can't figure how solve it.
I've this :
struct ArcSelectionView: View {
#Binding var isShowing: Bool
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State var colors = [Color.blue, Color.red, Color.green, Color.yellow]
var body: some View {
ZStack {
ForEach(1 ..< 5, id: \.self) { item in
Circle()
.trim(from: self.isShowing ? CGFloat((Double(item) * 0.25) - 0.25) : CGFloat(Double(item) * 0.25),
to: CGFloat(Double(item) * 0.25))
.stroke(self.colors[item - 1], lineWidth: 50)
.frame(width: 300, height: 300)
.onTapGesture {
if colors[item - 1] == colorToPress {
score += 1
}
isShowing.toggle()
colorToPress = colors.randomElement() ?? Color.offWhite
colors.shuffle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.isShowing.toggle()
}
}
}
}
.opacity(self.isShowing ? 1 : 0)
.rotationEffect(.degrees(self.isShowing ? 0 : 180))
.animation(.linear(duration: 0.35))
}
}
If I didn't shuffle colors in the .onTapGesture, everything is ok. But If I do, I've a strange plain Circle that appears in the middle and disappear after. It's ugly. Ugly Circle
Thank you for your help !
The issue is with the animation of the Circles. The better solution is to use arc shapes. Here is a working solution:
struct ArcSelectionView: View {
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State private var colors = [Color.blue, Color.red, Color.green, Color.yellow]
#State private var pct: CGFloat = 0.25
#State private var originalPCT: CGFloat = 0.25
let duration: Double = 0.35
var body: some View {
ZStack {
CircleView(wedge: originalPCT)
// I am not sure why, but at there is a difference of 10 in the sizes of the
// circle and the modifier. This corrects for it so the touch is accurate.
.frame(width: 310, height: 310)
PercentageArc(Color.clear, colors: colors, pct: pct) {
// With this solution you must have the callback sent to
// the main thread. This was unnecessary with AnimatbleModifier.
DispatchQueue.main.async {
pct = originalPCT
}
}
.animation(.linear(duration: duration), value: pct)
.frame(width: 300, height: 300)
// This forces the view to ignore taps.
.allowsHitTesting(false)
}
.onAppear {
pct = 1.0 / CGFloat(colors.count)
originalPCT = pct
}
}
func CircleView(wedge: CGFloat) -> some View {
ZStack {
// Array(zip()) is a cleaner and safe way of using indices AND you
// have the original object to use as well.
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
Circle()
.trim(from: CGFloat((Double(index) * wedge)),
to: CGFloat(Double(index + 1) * wedge))
// The color of the stroke should match your background color.
// Clear won't work.
.stroke(.white, lineWidth: 50)
.onTapGesture {
if color == colorToPress {
score += 1
print("score!")
}
pct = 0
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
colorToPress = colors.randomElement() ?? .white
colors.shuffle()
}
}
}
}
}
}
struct PercentageArc<Content>: View, Animatable where Content: View {
private var content: Content
private var colors: [Color]
private var pct: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(_ content: Content, colors: [Color], pct: CGFloat, onEnded: #escaping () -> () = {}) {
self.content = content
self.colors = colors
self.pct = pct
self.target = pct
self.onEnded = onEnded
}
var animatableData: CGFloat {
get { pct }
set { pct = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
var body: some View {
content
.overlay(
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
ArcPortionShape(pct: pct, startAngle: .degrees(1.0 / CGFloat(colors.count) * CGFloat(index) * 360.0))
.foregroundColor(color)
}
)
}
struct ArcPortionShape: InsettableShape {
let pct: CGFloat
let startAngle: Angle
var insetAmount = 0.0
init(pct: CGFloat, startAngle: Angle) {
self.pct = pct
self.startAngle = startAngle
}
var portion: CGFloat {
pct * 360.0
}
var endAngle: Angle {
.degrees(startAngle.degrees + portion)
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
return p.strokedPath(.init(lineWidth: 50))
}
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}
}
}
Originally, I made this with an AnimatableModifier, but it is deprecated, and the solution using it fails if it is placed in ANY stack or NavigationView. I can see why AnimatableModifier is deprecated.
This solution draws inspiration from this answer from Asperi, for the callback idea, though the solution will not work in iOS 15.2.

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

Resources