I'm working on a SwiftUI modifier (ZoomableModifier) that will be predominantly used for making images zoomable. I want this to work on both iOS and watchOS, but particularly on watchOS. I want it to be possible to use the digital crown to zoom in and out, and to achieve this I'm using the .digitalCrownRotation() modifier to read the rotation of the digital crown and use that to determine the scale factor.
Here's the code for the modifier:
//
// ZoomableModifier.swift
//
// Created by jasu on 2022/01/26.
// Modified by Toni Sucic on 2023/01/08.
// Copyright (c) 2022 jasu All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is furnished
// to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
// CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
// OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import SwiftUI
public struct ZoomableModifier: ViewModifier {
private enum ZoomState {
case inactive
case active(scale: CGFloat)
var scale: CGFloat {
switch self {
case .active(let scale):
return scale
default:
return 1.0
}
}
}
/// The content size of the views.
let contentSize: CGSize
/// The minimum value that can be zoom out.
let min: CGFloat
/// The maximum value that can be zoom in.
let max: CGFloat
/// A value that indicates whether the scroll view displays the scrollable component of the content offset, in a way that’s suitable for the platform.
let showsIndicators: Bool
#if os(iOS)
#GestureState private var zoomState = ZoomState.inactive
#endif
#State private var currentScale: CGFloat = 1.0
var scale: CGFloat {
#if os(iOS)
currentScale * zoomState.scale
#elseif os(watchOS)
currentScale
#endif
}
#if os(iOS)
var zoomGesture: some Gesture {
MagnificationGesture()
.updating($zoomState) { value, state, transaction in
state = .active(scale: value)
}
.onEnded { value in
var new = currentScale * value
if new <= min { new = min }
if new >= max { new = max }
currentScale = new
}
}
#endif
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if scale <= min { currentScale = max } else
if scale >= max { currentScale = min } else {
currentScale = ((max - min) * 0.5 + min) < scale ? max : min
}
}
}
public func body(content: Content) -> some View {
ScrollView([.vertical, .horizontal], showsIndicators: showsIndicators) {
content
.frame(width: contentSize.width * scale, height: contentSize.height * scale, alignment: .center)
.scaleEffect(scale, anchor: .center)
#if os(watchOS)
.focusable(true)
.digitalCrownRotation(detent: $currentScale,
from: min,
through: max,
by: 0.1,
sensitivity: .low,
isContinuous: false,
isHapticFeedbackEnabled: true)
#endif
}
#if os(iOS)
.gesture(ExclusiveGesture(zoomGesture, doubleTapGesture))
#elseif os(watchOS)
.gesture(doubleTapGesture)
#endif
.animation(.easeInOut, value: scale)
}
}
extension View {
func zoomable(contentSize: CGSize,
min: CGFloat = 1.0,
max: CGFloat = 3.0,
showsIndicators: Bool = false) -> some View {
modifier(ZoomableModifier(contentSize: contentSize, min: min, max: max, showsIndicators: showsIndicators))
}
}
I'm using the modifier in this view:
struct FullscreenImage: View {
let imageURL: URL
#Environment(\.dismiss) var dismiss
var body: some View {
GeometryReader { proxy in
AsyncImage(url: imageURL) { imagePhase in
Group {
switch imagePhase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: proxy.size.width, height: proxy.size.height)
.zoomable(contentSize: proxy.size)
case .failure(let error):
Text(error.localizedDescription)
.font(.caption)
.padding()
default:
Rectangle()
.fill(Color.white.opacity(0.2))
}
}
.animation(.easeInOut, value: imagePhase)
}
}
}
}
I would expect there to be a way to disable digital crown scrolling for the scroll view when using the .digitalCrownRotation() modifier, but I haven't found one. Thus, when I rotate the digital crown, it tries to scroll the scroll view instead of changing the scale factor to get the zoom effect I'm looking for.
Related
I've been playing around with giving views a gradient shadow (taken from here and here) and while these achieve most of what I need, they seem to have a flaw: the extension requires you to set a .frame height, otherwise the gradient looks really desaturated (as it's taking up the entire height of the device screen). It's a little hard to describe, so here's the code:
struct RainbowShadowCard: View {
#State private var cardGeometryHeight: CGFloat = 0.0
#State private var cardGeometryWidth: CGFloat = 0.0
var body: some View {
VStack {
Text("This is a card, it's pretty nice. It has a couple of lines of text inside it. Here are some more lines to see how it scales.")
.font(.system(.body, design: .rounded).weight(.medium))
}
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background {
GeometryReader { geo in
Color.black
.onAppear {
cardGeometryHeight = geo.size.height
cardGeometryWidth = geo.size.width
print("H: \(cardGeometryHeight), W: \(cardGeometryWidth)")
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
.multicolorGlow(cardHeight: cardGeometryHeight, cardWidth: cardGeometryWidth)
}
}
extension View {
func multicolorGlow(cardHeight: CGFloat, cardWidth: CGFloat) -> some View {
ZStack {
ForEach(0..<2) { i in
Rectangle()
.fill(
LinearGradient(colors: [
.red,
.green
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
// The height of the frame dictates the saturation of
// the linear gradient. Without it, the gradient takes
// up the full width and height of the screen, resulting in
// a washed out / desaturated gradient around the card.
.frame(height: 300)
// My attempt at making the height and width of this view
// be based off the parent view
/*
.frame(width: cardWidth, height: cardHeight)
*/
.mask(self.blur(radius: 10))
.overlay(self.blur(radius: 0))
}
}
}
}
struct RainbowShadowCard_Previews: PreviewProvider {
static var previews: some View {
RainbowShadowCard()
}
}
I've managed to successfully store the VStack height and width in cardGeometryHeight and cardGeometryWidth states respectfully, but I can't figure out how to correctly pass that into the extension.
In the extension, if I uncomment:
.frame(width: cardWidth, height: cardHeight)
The VStack goes to a square of 32x32.
Edit
For the sake of clarity, the above solution "works" if you don't use a frame height value for the extension, but it doesn't work very nicely. Compare the saturation of the shadow in this image to the original, and you'll see a big difference between a non framed approach and a framed approach. The reason for this muddier gradient is the extension is using the screen bounds for the linear gradient, so our shadow gradient isn't getting the benefit of the "start" and "end" saturation of the red and green, but the middle blending of the two.
I want to pinch zoom an image on a view.
I have this code:
#State var progressingScale: CGFloat = 1
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.leading, .trailing], 20)
.scaleEffect(progressingScale)
.gesture(MagnificationGesture()
.onChanged { value in
progressingScale = value.magnitude
}
.onEnded {value in
progressingScale = value.magnitude
}
)
}
This code works relatively well but suppose I scale the image up and lift the fingers. Now the image is huge.
Then, I try to pinch and scale it up more. The image goes back to scale = 1 and starts scaling up again. I want it to start at the scale it was when the fingers were released the first time.
Cannot figure why... any ideas?
In order for the magnification to stick, you need to keep track of two values: the image's permanent scale (imageScale) and the current magnification value (magnifyBy). The scale applied to the image is the product of these two values (imageScale * magnifyBy).
While the MagnificationGesture is in progress, just change the magnifyBy property. When the gesture ends, permanently apply the magnification to the imageScale and set magnifyBy back to 1.
struct ContentView: View {
#State private var imageScale: CGFloat = 1
#State private var magnifyBy: CGFloat = 1
var body: some View {
Image(systemName: "globe")
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.leading, .trailing], 20)
.scaleEffect(imageScale * magnifyBy)
.gesture(MagnificationGesture()
.onChanged { value in
magnifyBy = value.magnitude
}
.onEnded {value in
imageScale *= value.magnitude
magnifyBy = 1
}
)
}
}
Edited:
Sorry for the original long story, following is a short minimal reproducible standalone example I can think of:
import SwiftUI
extension View {
/// get view's size and do something with it.
func getSize(action: #escaping (CGSize) -> Void) -> some View {
overlay(GeometryReader{ geo in
emptyView(size: geo.size, action: action)
})
}
// private empty view
private func emptyView(
size : CGSize,
action: #escaping (CGSize) -> Void
) -> some View {
action(size) // ⭐️ side effect❗️
return Color.clear
}
}
struct MyView: View {
#State private var size = CGSize(width: 300, height: 200)
#State private var ratio: CGFloat = 1
var body: some View {
VStack {
Spacer()
cell
Spacer()
controls
}
}
var cell: some View {
Color.pink
.overlay {
VStack {
Text("(\(Int(size.width)), \(Int(size.height)))")
Text("aspect ratio: \(String(format: "%.02f", ratio))")
}
}
.getSize { size in
print(size)
// although it works fine in Xcode preview,
// it seems this line never runs in the built app.
// (aspect ratio is updated in preview, but not in the built app)
ratio = size.width / size.height
// not even a single line in the console when run in the app.
print(ratio)
}
.frame(width: size.width, height: size.height)
}
var controls: some View {
VStack {
Slider(value: $size.width, in: 50...300, step: 1)
Slider(value: $size.height, in: 50...300, step: 1)
}
.padding(40)
}
}
Now the code above behaves differently in the Xcoe preview and the built app:
My question is why the built app is not updating the "ratio" part in the UI?
original long story below:
I was doing some custom layout for an array of items, and used GeometryReader to read the proposed size from parent and then tried to update some view states based on that size.
It worked perfectly fine in the Xcode preview, but failed to update (some) view states in the built app, as you can see in the following GIF:
The following code is used in the preview:
struct ItemsView_Previews: PreviewProvider {
static var previews: some View {
ItemsView()
.preferredColorScheme(.dark)
}
}
and the following is for the app's content view:
struct ContentView: View {
var body: some View {
ItemsView()
.overlay {
Text("Built App")
.font(.largeTitle)
.bold()
.foregroundColor(.orange)
.opacity(0.3)
.shadow(radius: 2)
}
}
}
as you can see, they both use exactly the same ItemsView, which is defined by the following code:
import SwiftUI
struct ItemsView: View {
#State private var size = CGSize(300, 300) // proposed size
#State private var rows = 0 // current # of rows
#State private var cols = 0 // current # of cols
#State private var ratio: CGFloat = 1 // current cell aspect ratio
#State private var idealRatio: CGFloat = 1 // ideal cell aspect ratio
let items = Array(1...20)
var body: some View {
VStack {
ScrollView {
itemsView // view for layed out items
}
controls // control size, cell ratio
}
.padding()
}
}
extension ItemsView {
/// a view with layed out item views
var itemsView: some View {
// layout item views
items.itemsView { size in // Array+ .itemsView()
// ⭐ inject layout instance
RatioRetainingLayout( // RatioRetainingLayout
idealRatio,
count: items.count,
in: size
)
} itemView: { i in
// ⭐ inject view builder
Color(hue: 0.8, saturation: 0.8, brightness: 0.5)
.padding(1)
.overlay {
Text("\(i)").shadow(radius: 2)
}
}
// ⭐ get proposed size from parent
.getSize { proposedSize in // 🌀View+ .getSize()
// ⭐ recompute layout
let layout = RatioRetainingLayout( // 👔 RatioRetainingLayout
idealRatio,
count: items.count,
in: proposedSize
)
// ⭐ update view states
rows = layout.rows
cols = layout.cols
ratio = layout.cellSize.aspectRatio // 🅿️ Vector2D: CGSize+ .aspectRatio
}
// ⭐ proposed size
.frame(size) // 🌀View+ .frame(size), .dimension()
.dimension(.topLeading, arrow: .blue, label: .orange)
.padding(4)
.shadowedBorder() // 🌀View+ .shadowedBorder()
.padding(40)
}
/// sliders to control proposed size, ideal ratio
var controls: some View {
SizeRatioControl( // 👔 SizeRatioControl
size: $size,
rows: $rows,
cols: $cols,
idealRatio: $idealRatio,
ratio: $ratio
)
}
}
I used some custom extensions, protocols and types to support the ItemsView struct, but I think they are not relevant, if you're interested, you can have a look at GitHub.
I think the most relevant part in the above code is the following, where it tries to update some view states with respect to the proposed size:
// ⭐ get proposed size from parent
.getSize { proposedSize in // 🌀View+ .getSize()
// ⭐ recompute layout
let layout = RatioRetainingLayout( // 👔 RatioRetainingLayout
idealRatio,
count: items.count,
in: proposedSize
)
// ⭐ update view states
rows = layout.rows
cols = layout.cols
ratio = layout.cellSize.aspectRatio // 🅿️ Vector2D: CGSize+ .aspectRatio
}
and the .getSize() part is a custom View extension which I used to get the proposed size from parent by using GeometryReader:
extension View {
/// get view's size and do something with it.
func getSize(action: #escaping (CGSize) -> Void) -> some View {
background(GeometryReader{ geo in
emptyView(size: geo.size, action: action)
})
}
// private empty view
private func emptyView(
size : CGSize,
action: #escaping (CGSize) -> Void
) -> some View {
action(size) // ⭐️ side effect❗️
return EmptyView()
}
}
While everything works fine in the Xcode preview, sadly it just doesn't work in the built app.
Am I doing something wrong with the SwiftUI view states? Please help. Thanks.
I finally come to realize that I've been keeping violating the most important rule in SwiftUI - the "source of truth" rule.
I shouldn't have made the ratio a #State private var in the first place, its value totally depends on size, and that means ratio should be a computed property instead.
So, with the following revision, everything works just fine:
(we don't even need the orginal .getSize() extension)
struct MyView: View {
// ⭐️ source of truth
#State private var size = CGSize(width: 300, height: 200)
// ⭐️ computed property
var ratio: CGFloat { size.width / size.height }
var body: some View {
VStack {
Spacer()
cell
Spacer()
controls
}
}
var cell: some View {
Color.pink
.overlay {
VStack {
Text("(\(Int(size.width)), \(Int(size.height)))")
Text("aspect ratio: \(String(format: "%.02f", ratio))")
}
}
.frame(width: size.width, height: size.height)
}
var controls: some View {
VStack {
Slider(value: $size.width, in: 50...300, step: 1)
Slider(value: $size.height, in: 50...300, step: 1)
}
.padding(40)
}
}
Inside of a Scrollview I use a LazyVStack with a pinned header, and based on the scroll position I manipulate the scale of that header.
In the LazyVStack, I have a ForEach iterating over some list of items. However, if I use a nested ForEach loop (to have, say, items grouped together by month), the scrollview becomes extremely glitchy/jumpy.
Minimum reproducible code:
struct Nested: View {
#State var yValueAtMinScale: Double = 100 // y value at which header is at minimum scale (used for linear interpolation)
#State var headerScale = 1.0
var restingScrollYValue = 240.0 // y value of scroll notifier when scrollview is at top
var body: some View {
ZStack {
ScrollView {
LazyVStack(pinnedViews: [.sectionHeaders]) {
Section(
header:
Circle()
.fill(Color.red)
.frame(width: 150, height: 150)
.scaleEffect(CGFloat(headerScale), anchor: .top)
.zIndex(-1)
) {
// scroll position notifier
// i set the header's scale based on this view's global y-coordinate
GeometryReader { geo -> AnyView in
let frame = geo.frame(in: .global)
print("miny", frame.minY)
DispatchQueue.main.async {
self.headerScale = calculateHeaderScale(frameY: frame.minY)
}
return AnyView(Rectangle()) // hack
}
.frame(height: 0) // zero height hack
ForEach(1...10, id: \.self) { j in
Section {
Text("\(j)")
// works without nested loop
ForEach(1...3, id: \.self) { i in
Rectangle()
.frame(height: 50)
.padding(.horizontal)
.id(UUID())
}
}
}
}
}
}
}
}
// interpolates some linear value bounded between 0.75 and 1.5x, based on the scroll value
func calculateHeaderScale(frameY: CGFloat) -> Double {
let minScale = 0.75
let linearValue = (1-minScale) * (Double(frameY) - yValueAtMinScale) / (restingScrollYValue - yValueAtMinScale) + minScale
return max( 0.75, min(1.5, linearValue) )
}
}
Removing the inner nested ForEach loop removes the problem. What could be going on here? I figured updating the header scale on every scroll update would be too many view updates and cause glitches, but that doesn't explain why it works with one ForEach loop.
In my app, I drag a View horizontally to set a position of a model object. I can also drag it downward and release it to delete the model object. When it has been dragged downward far enough, I indicate the potential for deletion by changing its appearance. The problem is that this change interrupts the DragGesture. I don't understand why this happens.
The example code below demonstrates the problem. You can drag the light blue box side to side. If you pull down, it and it turns to the "rays" system image, but the drag dies.
The DragGesture is applied to the ZStack with a size of 50x50. The ZStack should continue to exist across that state change, no? Why is the drag gesture dying?
struct ContentView: View {
var body: some View {
ZStack {
DraggableThing()
}.frame(width: 300, height: 300)
.border(Color.black, width: 1)
}
}
struct DraggableThing: View {
#State private var willDeleteIfDropped = false
#State private var xPosition: CGFloat = 150
var body: some View {
//Rectangle()
// .fill(willDeleteIfDropped ? Color.red : Color.blue.opacity(0.3))
ZStack {
if willDeleteIfDropped {
Image(systemName: "rays")
} else {
Rectangle().fill(Color.blue.opacity(0.3))
}
}
.frame(width: 50, height: 50)
.position(x: xPosition, y: 150)
.gesture(DragGesture()
.onChanged { val in
print("drag changed \(val.translation)")
self.xPosition = 150 + val.translation.width
self.willDeleteIfDropped = (val.translation.height > 25)
}
.onEnded { val in
print("drag ended")
self.xPosition = 150
}
)
}
}
You need to keep content, which originally captured gesture. So your goal can be achieved with the following changes:
ZStack {
Rectangle().fill(Color.blue.opacity(willDeleteIfDropped ? 0.0 : 0.3))
if willDeleteIfDropped {
Image(systemName: "rays")
}
}