SwiftUI Circular slider issue not displaying the correct stroke when using two control point - swift

I have a circular view with two control knobs and a stroke between the two of them as you can see from the image.
The values are correct but the problem is how could I do display the blue stroke to be the opposite? The user might want the range to be NW, N to NE, and not NE including S to NE but the user needs the ability to pick from both. The code below can be just be pasted in to show the same as the image.
import SwiftUI
struct CircularSliderView: View {
var body: some View {
VStack(){
DirectionView()
Spacer()
}
}
}
struct SwellCircularSliderView_Previews: PreviewProvider {
static var previews: some View {
CircularSliderView()
}
}
struct DirectionView: View {
#State var directionValue: CGFloat = 0.0
#State var secondaryDirectionValue: CGFloat = 0.0
var body: some View {
VStack {
Text("\(directionValue, specifier: "%.0f")° - \(secondaryDirectionValue, specifier: "%.0f")° \(Double().degreesToCompassDirection(degree: Double(directionValue))) - \(Double().degreesToCompassDirection(degree: Double(secondaryDirectionValue)))")
.font(.body)
DirectionControlView(directionValue: $directionValue, secondaryDirectionValue: $secondaryDirectionValue)
.padding(.top, 60)
Spacer()
}//: VSTACK
}
}
struct DirectionControlView: View {
#Binding var directionValue: CGFloat
#State var dirAngleValue: CGFloat = 0.0
#Binding var secondaryDirectionValue: CGFloat
#State var secondaryDirAngleValue: CGFloat = 0.0
let minimumValue: CGFloat = 0
let maximumValue: CGFloat = 360.0
let totalValue: CGFloat = 360.0
let knobRadius: CGFloat = 10.0
let radius: CGFloat = 125.0
private let tickHeight: CGFloat = 8
private let longTickHeight: CGFloat = 14
private let tickWidth: CGFloat = 2
func minimumTrimValue() -> CGFloat{
if directionValue > secondaryDirectionValue {
return secondaryDirectionValue/totalValue
} else {
return directionValue/totalValue
}
}
func maximumTrimValue() -> CGFloat{
if directionValue > secondaryDirectionValue {
return directionValue/totalValue
} else {
return secondaryDirectionValue/totalValue
}
}
var body: some View {
ZStack {
Circle()
.trim(from: minimumTrimValue(), to: maximumTrimValue())
.stroke(
AngularGradient(gradient: Gradient(
colors: [Color.blue.opacity(0.2), Color.blue.opacity(1), Color.blue.opacity(0.2)]),
center: .center,
startAngle: .degrees(Double(secondaryDirectionValue)),
endAngle: .degrees(Double(directionValue))),
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: radius * 2, height: radius * 2)
.rotationEffect(.degrees(-90))
KnobCircle(radius: knobRadius * 2, padding: 6)
.offset(y: -radius)
.rotationEffect(Angle.degrees(Double(dirAngleValue)))
.shadow(color: Color.black.opacity(0.2), radius: 3, x: -3)
.gesture(DragGesture(minimumDistance: 0.0)
.onChanged({ angleValue in
knobChange(location: angleValue.location)
}))
KnobCircle(radius: knobRadius * 2, padding: 6)
.offset(y: -radius)
.rotationEffect(Angle.degrees(Double(secondaryDirectionValue)))
.shadow(color: Color.black.opacity(0.2), radius: 3, x: -3)
.gesture(DragGesture(minimumDistance: 0.0)
.onChanged({ angleValue in
knobSecondaryChange(location: angleValue.location)
}))
CompassView(count: 240,
longDivider: 15,
longTickHeight: self.longTickHeight,
tickHeight: self.tickHeight,
tickWidth: self.tickWidth,
highlightedColorDivider: 30,
highlightedColor: .blue,
normalColor: .black.opacity(0.2))
.frame(width: 350, height: 350)
CompassNumber(numbers: self.getNumbers(count: 16))
.frame(width: 310, height: 310)
}//: ZSTACK
.onAppear(){
updateInitialValue()
}
}
private func getNumbers(count: Int) -> [Float] {
var numbers: [Float] = []
numbers.append(Float(count) * 30)
for index in 1..<count {
numbers.append(Float(index) * 30)
}
return numbers
}
private func updateInitialValue(){
directionValue = minimumValue
dirAngleValue = CGFloat(directionValue/totalValue) * 360
}
private func knobChange(location: CGPoint) {
let vector = CGVector(dx: location.x, dy: location.y)
let angle = atan2(vector.dy - knobRadius, vector.dx - knobRadius) + .pi/2.0
let fixedAngle = angle < 0.0 ? angle + 2.0 * .pi : angle
let value = fixedAngle / (2.0 * .pi) * totalValue
if value > minimumValue && value < maximumValue {
directionValue = value
dirAngleValue = fixedAngle * 180 / .pi
}
}
private func knobSecondaryChange(location: CGPoint) {
let vector = CGVector(dx: location.x, dy: location.y)
let angle = atan2(vector.dy - knobRadius, vector.dx - knobRadius) + .pi/2.0
let fixedAngle = angle < 0.0 ? angle + 2.0 * .pi : angle
let value = fixedAngle / (2.0 * .pi) * totalValue
if value > minimumValue && value < maximumValue {
secondaryDirectionValue = value
secondaryDirAngleValue = fixedAngle * 180 / .pi
}
}
}
struct KnobCircle: View {
let radius: CGFloat
let padding: CGFloat
var body: some View {
ZStack(){
Circle()
.fill(Color.init(white: 0.96))
.frame(width: radius, height: radius)
.shadow(color: Color.black.opacity(0.1), radius: 10, x: -10, y: 8)
Circle()
.fill(Color.white)
.frame(width: radius - padding, height: radius - padding)
}//: ZSTACK
}
}
struct CompassView: View {
let count: Int
let longDivider: Int
let longTickHeight: CGFloat
let tickHeight: CGFloat
let tickWidth: CGFloat
let highlightedColorDivider: Int
let highlightedColor: Color
let normalColor: Color
var body: some View {
ZStack(){
ForEach(0..<self.count) { index in
let height = (index % self.longDivider == 0) ? self.longTickHeight : self.tickHeight
let color = (index % self.highlightedColorDivider == 0) ? self.highlightedColor : self.normalColor
let degree: Double = Double.pi * 2 / Double(self.count)
TickShape(tickHeight: height)
.stroke(lineWidth: self.tickWidth)
.rotationEffect(.radians(degree * Double(index)))
.foregroundColor(color)
}
}//: ZSTACK
}//: VIEW
}
struct TickShape: Shape {
let tickHeight: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY + self.tickHeight))
return path
}
}
struct CompassNumber: View {
let numbers: [Float]
let direction: [String] = ["N","NE","E","SE","S","SW","W","NW"]
var body: some View {
ZStack(){
ForEach(0..<self.direction.count) { index in
let degree: Double = Double.pi * 2 / Double(self.direction.count)
let itemDegree = degree * Double(index)
VStack(){
Text(self.direction[index])
.font(.footnote)
.rotationEffect(.radians(-itemDegree))
.foregroundColor(.blue)
Spacer()
}//: VSTACK
.rotationEffect(.radians(itemDegree))
}
}//: ZSTACK
}
}
extension Double {
func degreesToCompassDirection(degree: Double) -> String {
switch degree {
case 0..<11.25:
return "N"
case 11.25..<33.75:
return "NNE"
case 33.75..<56.25:
return "NE"
case 56.25..<78.75:
return "ENE"
case 78.75..<101.25:
return "E"
case 101.25..<123.75:
return "ESE"
case 123.75..<146.25:
return "SE"
case 146.25..<168.75:
return "SSE"
case 168.75..<191.25:
return "S"
case 191.25..<213.75:
return "SSW"
case 213.75..<236.25:
return "SW"
case 236.25..<258.75:
return "WSW"
case 258.75..<281.25:
return "W"
case 281.25..<303.75:
return "WNW"
case 303.75..<326.25:
return "NW"
case 326.25..<348.75:
return "NNW"
case 348.75..<360:
return "N"
default:
return "ERROR"
}
}
}
Thanks for your help.

I did a ZStack and displayed Background Stroke. In Case I want to display over the 0 Position (North) I just swapped Background and foreground colors.
var body: some View {
ZStack {
ZStack{
Circle() //Background
.stroke((directionValue < secondaryDirectionValue) ? Color.white : Color.blue)
.frame(width: radius * 2, height: radius * 2)
.rotationEffect(.degrees(-90))
Circle() //Foreground
.trim(from: minimumTrimValue(), to: maximumTrimValue())
.stroke((directionValue < secondaryDirectionValue) ? Color.blue : Color.white)
.frame(width: radius * 2, height: radius * 2)
.rotationEffect(.degrees(-90))
}

Related

How to implement a slider whose minimum track color is begin from center(value=0) not left in SwiftUI

I want to custom Slider in SwiftUI. Just something like this.
I tried Slider with GeometryReader but it isn't working.
//MARK: Left - Right Balance
GeometryReader { geo in
VStack {
Text("\(String(format: "%.2f", balanceVolume))")
HStack {
Text("L")
Slider(value: $balanceVolume, in: minValue...maxValue, step: 0.1) {editing in
print("editing", editing)
isEditing = editing
if !editing {
player.pan = Float(balanceVolume)
}
}
.tint(.none)
.accentColor(.gray)
Text("R")
}
}
.padding(20)
}
Thank you you all.
I have created a simple custom slider, I hope it helps
Output:
Use:
struct slider: View {
#State var sliderPosition: Float = 50
var body: some View {
SliderView(value: $sliderPosition, bounds: 1...100).padding(.all)
}
}
Code:
struct SliderView: View {
let currentValue: Binding<Float>
let sliderBounds: ClosedRange<Int>
public init(value: Binding<Float>, bounds: ClosedRange<Int>) {
self.currentValue = value
self.sliderBounds = bounds
}
var body: some View {
GeometryReader { geomentry in
sliderView(sliderSize: geomentry.size)
}
}
#ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
let sliderViewYCenter = sliderSize.height / 2
let sliderViewXCenter = sliderSize.width / 2
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color.gray)
.frame(height: 3)
ZStack {
let sliderBoundDifference = sliderBounds.count
let stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)
let thumbLocation = CGFloat(currentValue.wrappedValue) * stepWidthInPixel
// Path between starting point to thumb
lineBetweenThumbs(from: .init(x: sliderViewXCenter, y: sliderViewYCenter), to: .init(x: thumbLocation, y: sliderViewYCenter))
// Thumb Handle
let thumbPoint = CGPoint(x: thumbLocation, y: sliderViewYCenter)
thumbView(position: thumbPoint, value: Float(currentValue.wrappedValue))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(dragLocation.x, sliderSize.width)
let newValue = Float(sliderBounds.lowerBound / sliderBounds.upperBound) + Float(xThumbOffset / stepWidthInPixel)
if newValue > Float(sliderBounds.lowerBound) && newValue < Float(sliderBounds.upperBound + 1) {
currentValue.wrappedValue = newValue
}
})
}
}
}
#ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
Path { path in
path.move(to: from)
path.addLine(to: to)
}.stroke(Color.blue, lineWidth: 4)
}
#ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
ZStack {
Text(String(round(value)))
.font(.headline)
.offset(y: -20)
Circle()
.frame(width: 24, height: 24)
.foregroundColor(.accentColor)
.shadow(color: Color.black.opacity(0.16), radius: 8, x: 0, y: 2)
.contentShape(Rectangle())
}
.position(x: position.x, y: position.y)
}
}

How can I scale proportionally in view? Swiftui

I would like to make my view scalable. However, when I change the size, the size of the ticks remain. The ticks should also scale down to fit the circle proportionally.
Is there a best practice approach here?
here is my code which I have used:
struct TestView: View {
var body: some View {
GeometryReader { geometry in
ZStack {
Circle()
.fill(Color.gray)
ForEach(0..<60*4) { tick in
Ticks.tick(at: tick)
}
}
}.frame(height: 100)
}
}
struct Ticks{
static func tick(at tick: Int) -> some View {
VStack {
Rectangle()
.fill(Color.primary)
.opacity(tick % 20 == 0 ? 1 : 0.4)
.frame(width: 2, height: tick % 4 == 0 ? 15 : 7)
Spacer()
}.rotationEffect(Angle.degrees(Double(tick)/(60) * 360))
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
Thanks!
You can pass along a scale factor based on the GeometryReader that you already have in place.
struct ContentView: View {
var body: some View {
TestView()
}
}
struct Hand: Shape {
let inset: CGFloat
let angle: Angle
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: inset, dy: inset)
var p = Path()
p.move(to: CGPoint(x: rect.midX, y: rect.midY))
p.addLine(to: position(for: CGFloat(angle.radians), in: rect))
return p
}
private func position(for angle: CGFloat, in rect: CGRect) -> CGPoint {
let angle = angle - (.pi/2)
let radius = min(rect.width, rect.height)/2
let xPos = rect.midX + (radius * cos(angle))
let yPos = rect.midY + (radius * sin(angle))
return CGPoint(x: xPos, y: yPos)
}
}
struct TickHands: View {
var scale: Double
#State private var dateTime = Date()
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Hand(inset: 105 * scale, angle: dateTime.hourAngle)
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
Hand(inset: 70 * scale, angle: dateTime.minuteAngle)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
Hand(inset: 40 * scale, angle: dateTime.secondAngle)
.stroke(Color.orange, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
Circle().fill(Color.orange).frame(width: 10)
}
.onReceive(timer) { (input) in
self.dateTime = input
}
}
}
extension Date {
var hourAngle: Angle {
return Angle (degrees: (360 / 12) * (self.hour + self.minutes / 60))
}
var minuteAngle: Angle {
return Angle(degrees: (self.minutes * 360 / 60))
}
var secondAngle: Angle {
return Angle (degrees: (self.seconds * 360 / 60))
}
}
extension Date {
var hour: Double {
return Double(Calendar.current.component(.hour, from: self))
}
var minutes: Double {
return Double(Calendar.current.component(.minute, from: self))
}
var seconds: Double {
return Double(Calendar.current.component(.second, from: self))
}
}
struct TestView: View {
var clockSize: CGFloat = 500
var body: some View {
GeometryReader { geometry in
ZStack {
let scale = geometry.size.height / 200
TickHands(scale: scale)
ForEach(0..<60*4) { tick in
Ticks.tick(at: tick, scale: scale)
}
}
}.frame(width: clockSize, height: clockSize)
}
}
struct Ticks{
static func tick(at tick: Int, scale: CGFloat) -> some View {
VStack {
Rectangle()
.fill(Color.primary)
.opacity(tick % 20 == 0 ? 1 : 0.4)
.frame(width: 2 * scale, height: (tick % 5 == 0 ? 15 : 7) * scale)
Spacer()
}
.rotationEffect(Angle.degrees(Double(tick)/(60) * 360))
}
}
Note that you may want to change how the scale effect changes the width vs the height -- for example, maybe you want the width to always be 2. Or, perhaps you want to use something like min(1, 2 * scale) to prevent the ticks from going above or below a certain size. But, the principal will be the same (ie using the scale factor). You can also adjust the 200 that I have to something that fits your ideal scaling algorithm.

Fill circle with wave animation in SwiftUI

I have created a circle in swiftUI, and I want to fill it with sine wave animation for the water wave effect/animation. I wanted to fill it with a similar look:
Below is my code:
import SwiftUI
struct CircleWaveView: View {
var body: some View {
Circle()
.stroke(Color.blue, lineWidth: 10)
.frame(width: 300, height: 300)
}
}
struct CircleWaveView_Previews: PreviewProvider {
static var previews: some View {
CircleWaveView()
}
}
I want to mostly implement it on SwiftUI so that I can support dark mode! Thanks for the help!
Here's a complete standalone example. It features a slider which allows you to change the percentage:
import SwiftUI
struct ContentView: View {
#State private var percent = 50.0
var body: some View {
VStack {
CircleWaveView(percent: Int(self.percent))
Slider(value: self.$percent, in: 0...100)
}
.padding(.all)
}
}
struct Wave: Shape {
var offset: Angle
var percent: Double
var animatableData: Double {
get { offset.degrees }
set { offset = Angle(degrees: newValue) }
}
func path(in rect: CGRect) -> Path {
var p = Path()
// empirically determined values for wave to be seen
// at 0 and 100 percent
let lowfudge = 0.02
let highfudge = 0.98
let newpercent = lowfudge + (highfudge - lowfudge) * percent
let waveHeight = 0.015 * rect.height
let yoffset = CGFloat(1 - newpercent) * (rect.height - 4 * waveHeight) + 2 * waveHeight
let startAngle = offset
let endAngle = offset + Angle(degrees: 360)
p.move(to: CGPoint(x: 0, y: yoffset + waveHeight * CGFloat(sin(offset.radians))))
for angle in stride(from: startAngle.degrees, through: endAngle.degrees, by: 5) {
let x = CGFloat((angle - startAngle.degrees) / 360) * rect.width
p.addLine(to: CGPoint(x: x, y: yoffset + waveHeight * CGFloat(sin(Angle(degrees: angle).radians))))
}
p.addLine(to: CGPoint(x: rect.width, y: rect.height))
p.addLine(to: CGPoint(x: 0, y: rect.height))
p.closeSubpath()
return p
}
}
struct CircleWaveView: View {
#State private var waveOffset = Angle(degrees: 0)
let percent: Int
var body: some View {
GeometryReader { geo in
ZStack {
Text("\(self.percent)%")
.foregroundColor(.black)
.font(Font.system(size: 0.25 * min(geo.size.width, geo.size.height) ))
Circle()
.stroke(Color.blue, lineWidth: 0.025 * min(geo.size.width, geo.size.height))
.overlay(
Wave(offset: Angle(degrees: self.waveOffset.degrees), percent: Double(percent)/100)
.fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
.clipShape(Circle().scale(0.92))
)
}
}
.aspectRatio(1, contentMode: .fit)
.onAppear {
withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
self.waveOffset = Angle(degrees: 360)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
CircleWaveView(percent: 58)
}
}

Crop image according to rectangle in SwiftUI

I have an Image that I can drag around using DragGesture(). I want to crop the image visible inside the Rectangle area. Here is my code...
struct CropImage: View {
#State private var currentPosition: CGSize = .zero
#State private var newPosition: CGSize = .zero
var body: some View {
VStack {
ZStack {
Image("test_pic")
.resizable()
.scaledToFit()
.offset(x: self.currentPosition.width, y: self.currentPosition.height)
Rectangle()
.fill(Color.black.opacity(0.3))
.frame(width: UIScreen.screenWidth * 0.7 , height: UIScreen.screenHeight/5)
.overlay(Rectangle().stroke(Color.white, lineWidth: 3))
}
.gesture(DragGesture()
.onChanged { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
}
.onEnded { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
self.newPosition = self.currentPosition
})
Button ( action : {
// how to crop the image according to rectangle area
} ) {
Text("Crop Image")
.padding(.all, 10)
.background(Color.blue)
.foregroundColor(.white)
.shadow(color: .gray, radius: 1)
.padding(.top, 50)
}
}
}
}
For easier understanding...
Here is possible approach using .clipShape. Tested with Xcode 11.4 / iOS 13.4
struct CropFrame: Shape {
let isActive: Bool
func path(in rect: CGRect) -> Path {
guard isActive else { return Path(rect) } // full rect for non active
let size = CGSize(width: UIScreen.screenWidth * 0.7, height: UIScreen.screenHeight/5)
let origin = CGPoint(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2)
return Path(CGRect(origin: origin, size: size).integral)
}
}
struct CropImage: View {
#State private var currentPosition: CGSize = .zero
#State private var newPosition: CGSize = .zero
#State private var clipped = false
var body: some View {
VStack {
ZStack {
Image("test_pic")
.resizable()
.scaledToFit()
.offset(x: self.currentPosition.width, y: self.currentPosition.height)
Rectangle()
.fill(Color.black.opacity(0.3))
.frame(width: UIScreen.screenWidth * 0.7 , height: UIScreen.screenHeight/5)
.overlay(Rectangle().stroke(Color.white, lineWidth: 3))
}
.clipShape(
CropFrame(isActive: clipped)
)
.gesture(DragGesture()
.onChanged { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
}
.onEnded { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
self.newPosition = self.currentPosition
})
Button (action : { self.clipped.toggle() }) {
Text("Crop Image")
.padding(.all, 10)
.background(Color.blue)
.foregroundColor(.white)
.shadow(color: .gray, radius: 1)
.padding(.top, 50)
}
}
}
}
Thanks to Asperi's answer, I have implement a lightweight swiftUI library to crop image.Here is the library and demo. Demo
The magic is below:
public var body: some View {
GeometryReader { proxy in
// ...
Button(action: {
// how to crop the image according to rectangle area
if self.tempResult == nil {
self.cropTheImageWithImageViewSize(proxy.size)
}
self.resultImage = self.tempResult
}) {
Text("Crop Image")
.padding(.all, 10)
.background(Color.blue)
.foregroundColor(.white)
.shadow(color: .gray, radius: 1)
.padding(.top, 50)
}
}
}
func cropTheImageWithImageViewSize(_ size: CGSize) {
let imsize = inputImage.size
let scale = max(inputImage.size.width / size.width,
inputImage.size.height / size.height)
let zoomScale = self.scale
let currentPositionWidth = self.dragAmount.width * scale
let currentPositionHeight = self.dragAmount.height * scale
let croppedImsize = CGSize(width: (self.cropSize.width * scale) / zoomScale, height: (self.cropSize.height * scale) / zoomScale)
let xOffset = (( imsize.width - croppedImsize.width) / 2.0) - (currentPositionWidth / zoomScale)
let yOffset = (( imsize.height - croppedImsize.height) / 2.0) - (currentPositionHeight / zoomScale)
let croppedImrect: CGRect = CGRect(x: xOffset, y: yOffset, width: croppedImsize.width, height: croppedImsize.height)
if let cropped = inputImage.cgImage?.cropping(to: croppedImrect) {
//uiimage here can write to data in png or jpeg
let croppedIm = UIImage(cgImage: cropped)
tempResult = croppedIm
result = Image(uiImage: croppedIm)
}
}
Hear is code
// Created by Deepak Gautam on 15/02/22.
import SwiftUI
struct ImageCropView: View {
#Environment(\.presentationMode) var pm
#State var imageWidth:CGFloat = 0
#State var imageHeight:CGFloat = 0
#Binding var image : UIImage
#State var dotSize:CGFloat = 13
var dotColor = Color.init(white: 1).opacity(0.9)
#State var center:CGFloat = 0
#State var activeOffset:CGSize = CGSize(width: 0, height: 0)
#State var finalOffset:CGSize = CGSize(width: 0, height: 0)
#State var rectActiveOffset:CGSize = CGSize(width: 0, height: 0)
#State var rectFinalOffset:CGSize = CGSize(width: 0, height: 0)
#State var activeRectSize : CGSize = CGSize(width: 200, height: 200)
#State var finalRectSize : CGSize = CGSize(width: 200, height: 200)
var body: some View {
ZStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.overlay(GeometryReader{geo -> AnyView in
DispatchQueue.main.async{
self.imageWidth = geo.size.width
self.imageHeight = geo.size.height
}
return AnyView(EmptyView())
})
Text("Crop")
.padding(6)
.foregroundColor(.white)
.background(Capsule().fill(Color.blue))
.offset(y: -250)
.onTapGesture {
let cgImage: CGImage = image.cgImage!
let scaler = CGFloat(cgImage.width)/imageWidth
if let cImage = cgImage.cropping(to: CGRect(x: getCropStartCord().x * scaler, y: getCropStartCord().y * scaler, width: activeRectSize.width * scaler, height: activeRectSize.height * scaler)){
image = UIImage(cgImage: cImage)
}
pm.wrappedValue.dismiss()
}
Rectangle()
.stroke(lineWidth: 1)
.foregroundColor(.white)
.offset(x: rectActiveOffset.width, y: rectActiveOffset.height)
.frame(width: activeRectSize.width, height: activeRectSize.height)
Rectangle()
.stroke(lineWidth: 1)
.foregroundColor(.white)
.background(Color.green.opacity(0.3))
.offset(x: rectActiveOffset.width, y: rectActiveOffset.height)
.frame(width: activeRectSize.width, height: activeRectSize.height)
.gesture(
DragGesture()
.onChanged{drag in
let workingOffset = CGSize(
width: rectFinalOffset.width + drag.translation.width,
height: rectFinalOffset.height + drag.translation.height
)
self.rectActiveOffset.width = workingOffset.width
self.rectActiveOffset.height = workingOffset.height
activeOffset.width = rectActiveOffset.width - activeRectSize.width / 2
activeOffset.height = rectActiveOffset.height - activeRectSize.height / 2
}
.onEnded{drag in
self.rectFinalOffset = rectActiveOffset
self.finalOffset = activeOffset
}
)
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 12))
.background(Circle().frame(width: 20, height: 20).foregroundColor(dotColor))
.frame(width: dotSize, height: dotSize)
.foregroundColor(.black)
.offset(x: activeOffset.width, y: activeOffset.height)
.gesture(
DragGesture()
.onChanged{drag in
let workingOffset = CGSize(
width: finalOffset.width + drag.translation.width,
height: finalOffset.height + drag.translation.height
)
let changeInXOffset = finalOffset.width - workingOffset.width
let changeInYOffset = finalOffset.height - workingOffset.height
if finalRectSize.width + changeInXOffset > 40 && finalRectSize.height + changeInYOffset > 40{
self.activeOffset.width = workingOffset.width
self.activeOffset.height = workingOffset.height
activeRectSize.width = finalRectSize.width + changeInXOffset
activeRectSize.height = finalRectSize.height + changeInYOffset
rectActiveOffset.width = rectFinalOffset.width - changeInXOffset / 2
rectActiveOffset.height = rectFinalOffset.height - changeInYOffset / 2
}
}
.onEnded{drag in
self.finalOffset = activeOffset
finalRectSize = activeRectSize
rectFinalOffset = rectActiveOffset
}
)
}
.onAppear {
activeOffset.width = rectActiveOffset.width - activeRectSize.width / 2
activeOffset.height = rectActiveOffset.height - activeRectSize.height / 2
finalOffset = activeOffset
}
}
func getCropStartCord() -> CGPoint{
var cropPoint : CGPoint = CGPoint(x: 0, y: 0)
cropPoint.x = imageWidth / 2 - (activeRectSize.width / 2 - rectActiveOffset.width )
cropPoint.y = imageHeight / 2 - (activeRectSize.height / 2 - rectActiveOffset.height )
return cropPoint
}
}
struct TestCrop : View{
#State var imageWidth:CGFloat = 0
#State var imageHeight:CGFloat = 0
#State var image:UIImage
#State var showCropper : Bool = false
var body: some View{
VStack{
Text("Open Cropper")
.font(.system(size: 17, weight: .medium))
.padding(.horizontal, 15)
.padding(.vertical, 10)
.foregroundColor(.white)
.background(Capsule().fill(Color.blue))
.onTapGesture {
showCropper = true
}
Image(uiImage: image)
.resizable()
.scaledToFit()
}
.sheet(isPresented: $showCropper) {
//
} content: {
ImageCropView(image: $image)
}
}
}
struct TestViewFinder_Previews: PreviewProvider {
static var originalImage = UIImage(named: "food")
static var previews: some View {
TestCrop(image: originalImage ?? UIImage())
}
}

SwiftUI Scroll/List Scrolling Events

lately I have been trying to create a pull to (refresh, load more) swiftUI Scroll View !!, inspired by https://cocoapods.org/pods/SwiftPullToRefresh
I was struggling to get the offset and the size of the content. but now I am struggling to get the event when the user releases the scroll view to finish the UI.
here is my current code:
struct PullToRefresh2: View {
#State var offset : CGPoint = .zero
#State var contentSize : CGSize = .zero
#State var scrollViewRect : CGRect = .zero
#State var items = (0 ..< 50).map { "Item \($0)" }
#State var isTopRefreshing = false
#State var isBottomRefreshing = false
var top : CGFloat {
return self.offset.y
}
private var bottomLocation : CGFloat {
if contentSize.height >= scrollViewRect.height {
return self.contentSize.height + self.top - self.scrollViewRect.height + 32
}
return top + 32
}
private var shouldTopRefresh : Bool {
return self.top > 80
}
private var shouldBottomRefresh : Bool {
return self.bottomLocation < -80 + 32
}
func watchOffset() -> Binding<CGPoint> {
return .init(get: {
return self.offset
},set: {
print("watched : offset= \($0)")
self.offset = $0
})
}
private func computeOffset() -> CGFloat {
if isTopRefreshing {
print("OFFSET: isTopRefreshing")
return 32
} else if isBottomRefreshing {
if (contentSize.height+32) < scrollViewRect.height {
print("OFFSET: isBottomRefreshing 1")
return top
} else if scrollViewRect.height > contentSize.height {
print("OFFSET: isBottomRefreshing 2")
return 32 - (scrollViewRect.height - contentSize.height)
} else {
print("OFFSET: isBottomRefreshing 3")
return scrollViewRect.height - contentSize.height - 32
}
}
print("OFFSET: fall back->\(top)")
return top
}
func watchScrollViewRect() -> Binding<CGRect> {
return .init(get: {
return self.scrollViewRect
},set: {
print("watched : scrollViewRect= \($0)")
self.scrollViewRect = $0
})
}
func watchContentSize() -> Binding<CGSize> {
return .init(get: {
return self.contentSize
},set: {
print("watched : contentSize= \($0)")
self.contentSize = $0
})
}
func newDragGuesture() -> some Gesture {
return DragGesture()
.onChanged { _ in
print("> drag changed")
}
.onEnded { _ in
DispatchQueue.main.async {
print("> drag ended")
self.isTopRefreshing = self.shouldTopRefresh
self.isBottomRefreshing = self.shouldTopRefresh
withAnimation {
self.offset = CGPoint.init(x: self.offset.x, y: self.computeOffset())
}
}
}
}
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Text("Back")
}
ZStack {
OffsetScrollView(.vertical, showsIndicators: true,
offset: self.watchOffset(),
contentSize: self.watchContentSize(),
scrollViewFrame: self.watchScrollViewRect())
{
VStack {
ForEach(self.items, id: \.self) { item in
HStack {
Text("\(item)")
.font(.system(Font.TextStyle.title))
.fontWeight(.regular)
//.frame(width: geo.size.width)
//.background(Color.blue)
.padding(.horizontal, 8)
Spacer()
}
//.background(Color.red)
.padding(.bottom, 8)
}
}//.background(Color.clear)
}.edgesIgnoringSafeArea(.horizontal)
.background(Color.red)
//.simultaneousGesture(self.newDragGuesture())
VStack {
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldTopRefresh ? -180 : 0))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.top - 32))
.animation(nil)
.opacity(self.isTopRefreshing ? 0 : 1)
Spacer()
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldBottomRefresh ? 0 : -180))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.bottomLocation))
.animation(nil)
.opacity(self.isBottomRefreshing ? 0 : 1)
}
// Color.init(.sRGB, white: 0.2, opacity: 0.7)
//
// .simultaneousGesture(self.newDragGuesture())
}
.clipped()
.clipShape(Rectangle())
Text("Offset: \(String(describing: self.offset))")
Text("contentSize: \(String(describing: self.contentSize))")
Text("scrollViewRect: \(String(describing: self.scrollViewRect))")
}
}
}
//https://zacwhite.com/2019/scrollview-content-offsets-swiftui/
public struct OffsetScrollView<Content>: View where Content : View {
/// The content of the scroll view.
public var content: Content
/// The scrollable axes.
///
/// The default is `.vertical`.
public var axes: Axis.Set
/// If true, the scroll view may indicate the scrollable component of
/// the content offset, in a way suitable for the platform.
///
/// The default is `true`.
public var showsIndicators: Bool
/// The initial offset of the view as measured in the global frame
#State private var initialOffset: CGPoint?
/// The offset of the scroll view updated as the scroll view scrolls
#Binding public var scrollViewFrame: CGRect
#Binding public var offset: CGPoint
#Binding public var contentSize: CGSize
public init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
offset: Binding<CGPoint> = .constant(.zero),
contentSize: Binding<CGSize> = .constant(.zero) ,
scrollViewFrame: Binding<CGRect> = .constant(.zero),
#ViewBuilder content: () -> Content) {
self.axes = axes
self.showsIndicators = showsIndicators
self._offset = offset
self._contentSize = contentSize
self.content = content()
self._scrollViewFrame = scrollViewFrame
}
public var body: some View {
ZStack {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
self.$scrollViewFrame.wrappedValue = frame
}
}
ScrollView(axes, showsIndicators: showsIndicators) {
ZStack(alignment: .leading) {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
let globalOrigin = frame.origin
self.initialOffset = self.initialOffset ?? globalOrigin
let initialOffset = (self.initialOffset ?? .zero)
let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
self.$offset.wrappedValue = offset
self.$contentSize.wrappedValue = frame.size
}
}
content
}
}
}
}
}
struct Run: View {
let block: () -> Void
var body: some View {
DispatchQueue.main.async(execute: block)
return AnyView(EmptyView())
}
}
extension CGPoint {
func reScale(from: CGRect, to: CGRect) -> CGPoint {
let x = (self.x - from.origin.x) / from.size.width * to.size.width + to.origin.x
let y = (self.y - from.origin.y) / from.size.height * to.size.height + to.origin.y
return .init(x: x, y: y)
}
func center(from: CGRect, to: CGRect) -> CGPoint {
let x = self.x + (to.size.width - from.size.width) / 2 - from.origin.x + to.origin.x
let y = self.y + (to.size.height - from.size.height) / 2 - from.origin.y + to.origin.y
return .init(x: x, y: y)
}
}
enum ArrowContentMode {
case center
case reScale
}
extension ArrowContentMode {
func transform(point: CGPoint, from: CGRect, to: CGRect) -> CGPoint {
switch self {
case .center:
return point.center(from: from, to: to)
case .reScale:
return point.reScale(from: from, to: to)
}
}
}
struct ArrowShape : Shape {
let contentMode : ArrowContentMode = .center
func path(in rect: CGRect) -> Path {
var path = Path()
let points = [
CGPoint(x: 0, y: 8),
CGPoint(x: 0, y: -8),
CGPoint(x: 0, y: 8),
CGPoint(x: 5.66, y: 2.34),
CGPoint(x: 0, y: 8),
CGPoint(x: -5.66, y: 2.34)
]
let minX = points.min { $0.x < $1.x }?.x ?? 0
let minY = points.min { $0.y < $1.y }?.y ?? 0
let maxX = points.max { $0.x < $1.x }?.x ?? 0
let maxY = points.max { $0.y < $1.y }?.y ?? 0
let fromRect = CGRect.init(x: minX, y: minY, width: maxX-minX, height: maxY-minY)
print("fromRect nx: ",minX,minY,maxX,maxY)
print("fromRect: \(fromRect), toRect: \(rect)")
let transformed = points.map { contentMode.transform(point: $0, from: fromRect, to: rect) }
print("fromRect: transformed=>\(transformed)")
path.move(to: transformed[0])
path.addLine(to: transformed[1])
path.move(to: transformed[2])
path.addLine(to: transformed[3])
path.move(to: transformed[4])
path.addLine(to: transformed[5])
return path
}
}
what I need is a way to tell when the user releases the scrollview, and if the pull to refresh arrow passed the threshold and was rotated, the scroll will move to a certain offset (say 32), and hide the arrow and show an ActivityIndicator.
NOTE: I tried using DragGesture but:
* it wont work on the scroll view
* OR block the scrolling on the scrollview content
You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.
struct Example: View {
#State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
#State var offsetPublisher = Just(.zero).eraseToAnyPublisher()
var body: some View {
...
.introspectScrollView {
self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
}
.onReceive(isDraggingPublisher) {
// do something with isDragging change
}
.onReceive(offsetPublisher) {
// do something with offset change
}
...
}
If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.