When the user touches the screen, it will record the gesture as CGPoint and then display them with Path. But now the line is not smooth at the turning point. What should I do?
This is my code:
struct LineView: View {
#State var removeLine = false
#State var singleLineData = [CGPoint]()
var body: some View {
ZStack {
Rectangle()
.cornerRadius(20)
.opacity(0.1)
.shadow(color: .gray, radius: 4, x: 0, y: 2)
Path { path in
path.addLines(singleLineData)
}
.stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
.gesture(
DragGesture()
.onChanged { state in
if removeLine {
singleLineData.removeAll()
removeLine = false
}
singleLineData.append(state.location)
}
.onEnded { _ in
removeLine = true
}
)
.frame(width: 370, height: 500)
}
}
struct LineView_Previews: PreviewProvider {
static var previews: some View {
LineView()
}
}
I'm back and I know a solution to make line smoother.
First I created a class to return Path 「PS: I learned this from Mrs. Karin Prater's youtube course("How to make a drawing app with SwiftUI 3"), Shoutout to her」
class DrawingEngine {
func createPath(for points: [CGPoint]) -> Path {
var path = Path()
if let firstPoint = points.first {
path.move(to: firstPoint)
}
for index in 1..<points.count {
let mid = calculateMidPoint(points[index-1], points[index])
path.addQuadCurve(to: mid, control: points[index - 1])
}
if let last = points.last {
path.addLine(to: last)
}
return path
}
func calculateMidPoint(_ p1: CGPoint, _ p2: CGPoint) -> CGPoint {
let newMidPoint = CGPoint(x: (p1.x+p2.x)/2, y: (p1.y+p2.y)/2)
return newMidPoint
}
}
Then I only record new state points whose linear distance from the previous point exceeds a certain value(10 or 20...)
.gesture(DragGesture()
.onChanged { state in
if removeLine {
removeLine = false
singleLineData = [CGPoint]()
}
var exceedsMinimumDistance: Bool {
return sqrt(pow((singleLineData[singleLineData.count-1].x - state.location.x), 2) + pow((singleLineData[singleLineData.count-1].y - state.location.y), 2)) > 20
}
if singleLineData.count == 0 {
singleLineData.append(state.location)
} else if exceedsMinimumDistance {
singleLineData.append(state.location)
}
}
.onEnded { _ in
removeLine = true
})
This is full code of LineView:
struct LineView: View {
#State var removeLine = false
#State var singleLineData = [CGPoint]()
let engine = DrawingEngine()
let minimumDistance: CGFloat = 20
var body: some View {
ZStack {
Rectangle()
.cornerRadius(20)
.opacity(0.1)
.shadow(color: .gray, radius: 4, x: 0, y: 2)
if singleLineData.count != 0 {
Path { path in
path = engine.createPath(for: singleLineData)
}
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
}
}
.gesture(DragGesture()
.onChanged { state in
if removeLine {
removeLine = false
singleLineData = [CGPoint]()
}
var exceedsMinimumDistance: Bool {
return sqrt(pow((singleLineData[singleLineData.count-1].x - state.location.x), 2) + pow((singleLineData[singleLineData.count-1].y - state.location.y), 2)) > minimumDistance
}
if singleLineData.count == 0 {
singleLineData.append(state.location)
} else if exceedsMinimumDistance {
singleLineData.append(state.location)
}
}
.onEnded { _ in
removeLine = true
})
}
}
My English is not good, hope this solution could help you ;)
Related
As a beginner in Swift, I wanted to create a painting app where we can choose a photo to frame into a 3d frame.
I'm using SwiftUI and here is my code for the 3d scene.
import SwiftUI
import SceneKit
struct SceneView: UIViewRepresentable {
let scene = SCNScene()
func makeUIView(context: Context) -> SCNView {
// create a box
scene.rootNode.addChildNode(createBox())
scene.rootNode.addChildNode(createFrameY(ypos: 30))
scene.rootNode.addChildNode(createFrameY(ypos: -30))
scene.rootNode.addChildNode(createFrameX(xpos: 20))
scene.rootNode.addChildNode(createFrameX(xpos: -20))
// code for creating the camera and setting up lights is omitted for simplicity
// …
// retrieve the SCNView
let scnView = SCNView()
scnView.scene = scene
return scnView
}
func updateUIView(_ scnView: SCNView, context: Context) {
scnView.scene = scene
// allows the user to manipulate the camera
scnView.allowsCameraControl = true
// configure the view
scnView.backgroundColor = UIColor.gray
// show statistics such as fps and timing information
scnView.showsStatistics = true
}
func createBox() -> SCNNode {
let boxGeometry = SCNBox(width: 1, height: 60, length: 40, chamferRadius: 0)
let box = SCNNode(geometry: boxGeometry)
box.name = "box"
let material = SCNMaterial()
material.locksAmbientWithDiffuse = true;
material.isDoubleSided = false
material.diffuse.contents = "Mona"
boxGeometry.materials = [material]
box.localRotate(by: SCNQuaternion(x: 0, y: 0.7071, z: 0, w: 0.7071))
return box
}
func createFrameY(ypos: Float) -> SCNNode {
let boxGeometry = SCNBox(width: 2, height: 2, length: 42, chamferRadius: 0)
let box = SCNNode(geometry: boxGeometry)
box.name = "box"
box.position.y = ypos
boxGeometry.firstMaterial?.diffuse.contents = UIColor.black
box.localRotate(by: SCNQuaternion(x: 0, y: 0.7071, z: 0, w: 0.7071))
return box
}
func createFrameX(xpos: Float) -> SCNNode {
let boxGeometry = SCNBox(width: 2, height: 62, length: 2, chamferRadius: 0)
let box = SCNNode(geometry: boxGeometry)
box.name = "box"
box.position.x = xpos
boxGeometry.firstMaterial?.diffuse.contents = UIColor.black
return box
}
func removeBox() {
// check if box exists and remove it from the scene
guard let box = scene.rootNode.childNode(withName: "box", recursively: true) else { return }
box.removeFromParentNode()
}
func addBox() {
// check if box is already present, no need to add one
if scene.rootNode.childNode(withName: "box", recursively: true) != nil {
return
}
scene.rootNode.addChildNode(createBox())
}
}
#if DEBUG
struct ScenekitView_Previews : PreviewProvider {
static var previews: some View {
SceneView()
.previewDevice("iPhone 13 Pro")
}
}
#endif
As I understood, when working with UIViewRepresentable, you cannot have other elements to your screen. So I tried to use this code to have both buttons and the Scene View
import SwiftUI
import SceneKit
struct ContentVie: View {
var body: some View {
Home()
}
}
struct ContentVie_Previews: PreviewProvider {
static var previews: some View {
ContentVie()
}
}
// Home View...
struct Home : View {
#State var models = [
Model(id: 0, name: "Earth", modelName: "Earth.usdz", details: "Earth is the third planet from the Sun and the only astronomical object known to harbor life. According to radiometric dating estimation and other evidence, Earth formed over 4.5 billion years ago. Earth's gravity interacts with other objects in space, especially the Sun and the Moon, which is Earth's only natural satellite. Earth orbits around the Sun in 365.256 solar days.")
]
#State var index = 0
var scene = SCNScene(named: "Earth.usdz")
var body: some View{
VStack{
// Going to use SceneKit Scene View....
// default is first object ie: Earth...
// Scene View Has a default Camera View...
// if you nedd custom means add there...
SceneView(scene: scene, options: [.autoenablesDefaultLighting,.allowsCameraControl])
// for user action...
// setting custom frame...
.frame(width: UIScreen.main.bounds.width , height: UIScreen.main.bounds.height / 2)
ZStack{
// Forward and backward buttons...
HStack{
Button(action: {
withAnimation{
if index > 0{
index -= 1
}
}
}, label: {
Image(systemName: "chevron.left")
.font(.system(size: 35, weight: .bold))
.opacity(index == 0 ? 0.3 : 1)
})
.disabled(index == 0 ? true : false)
Spacer(minLength: 0)
Button(action: {
withAnimation{
if index < models.count{
index += 1
}
}
}, label: {
Image(systemName: "chevron.right")
.font(.system(size: 35, weight: .bold))
// disabling button when no other data ....
.opacity(index == models.count - 1 ? 0.3 : 1)
})
.disabled(index == models.count - 1 ? true : false)
}
Text(models[index].name)
.font(.system(size: 45, weight: .bold))
}
.foregroundColor(.black)
.padding(.horizontal)
.padding(.vertical,30)
// Details....
VStack(alignment: .leading, spacing: 15, content: {
Text("About")
.font(.title2)
.fontWeight(.bold)
Text(models[index].details)
})
.padding(.horizontal)
Spacer(minLength: 0)
}
}
}
// Data Model...
struct Model : Identifiable {
var id : Int
var name : String
var modelName : String
var details : String
}
But I cannot add SCNNodes to the scene. Do you know how I could combine both to make it work?
I'm trying to create circles that can be dragged and dropped.
I am able to get it to work with just the first circle, however, anything after the first doesn't work.
Expected behaviour: Circle follows my cursor while dragging and lands on final position when drag ends
Actual behaviour: Circle follows my cursor's horizontal position, but not vertical position (vertical position is always significantly below my cursor)
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(alignment: .center) {
ForEach(0..<5) { _ in
DraggableCircles()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct DraggableCircles: View {
#State var dragAmount: CGPoint = CGPoint.zero
var body: some View {
Circle().fill(Color.red)
.frame(width: 50, height: 50)
.gesture(
DragGesture(coordinateSpace: .global).onChanged {action in
let location = action.location
let newWidth = location.x
let newHeight = location.y
let size = CGPoint(x: newWidth, y: newHeight)
self.dragAmount = size
}.onEnded{action in
let location = action.location
let newWidth = location.x
let newHeight = location.y
let size = CGPoint(x: newWidth, y: newHeight)
self.dragAmount = size
}
)
.position(x: dragAmount.x, y: dragAmount.y)
}
}
You have to add drag value to the last location. The correct calculation is here.
struct DraggableCircles: View {
#State private var location: CGPoint = CGPoint(x: 50, y: 50)
#GestureState private var startLocation: CGPoint? = nil
var body: some View {
// Here is create DragGesture and handel jump when you again start the dragging/
let dragGesture = DragGesture()
.onChanged { value in
var newLocation = startLocation ?? location
newLocation.x += value.translation.width
newLocation.y += value.translation.height
self.location = newLocation
}.updating($startLocation) { (value, startLocation, transaction) in
startLocation = startLocation ?? location
}
return Circle().fill(Color.red)
.frame(width: 50, height: 50)
.position(location)
.gesture(dragGesture)
}
}
Is there a way, using SwiftUI, to create a Slider with 2 handles?
I'm working on a project that that requires settings a low and high point for a random value to be created between, and sliders seem to fit that need perfectly. I currently have it implemented as 2 separate sliders, but it would much rather have it 1 slider with 2 handles.
I've been searching and I cannot find any examples of it in SwiftUI, but I did find a webpage example of what I'm looking to do here: https://jqueryui.com/slider/#range
Is this possible in iOS via SwiftUI?
I've created a custom slider for you. I hope that's enough for your needs. Let me know if there is anything else I can do.
Slider:
import SwiftUI
import Combine
//SliderValue to restrict double range: 0.0 to 1.0
#propertyWrapper
struct SliderValue {
var value: Double
init(wrappedValue: Double) {
self.value = wrappedValue
}
var wrappedValue: Double {
get { value }
set { value = min(max(0.0, newValue), 1.0) }
}
}
class SliderHandle: ObservableObject {
//Slider Size
let sliderWidth: CGFloat
let sliderHeight: CGFloat
//Slider Range
let sliderValueStart: Double
let sliderValueRange: Double
//Slider Handle
var diameter: CGFloat = 40
var startLocation: CGPoint
//Current Value
#Published var currentPercentage: SliderValue
//Slider Button Location
#Published var onDrag: Bool
#Published var currentLocation: CGPoint
init(sliderWidth: CGFloat, sliderHeight: CGFloat, sliderValueStart: Double, sliderValueEnd: Double, startPercentage: SliderValue) {
self.sliderWidth = sliderWidth
self.sliderHeight = sliderHeight
self.sliderValueStart = sliderValueStart
self.sliderValueRange = sliderValueEnd - sliderValueStart
let startLocation = CGPoint(x: (CGFloat(startPercentage.wrappedValue)/1.0)*sliderWidth, y: sliderHeight/2)
self.startLocation = startLocation
self.currentLocation = startLocation
self.currentPercentage = startPercentage
self.onDrag = false
}
lazy var sliderDragGesture: _EndedGesture<_ChangedGesture<DragGesture>> = DragGesture()
.onChanged { value in
self.onDrag = true
let dragLocation = value.location
//Restrict possible drag area
self.restrictSliderBtnLocation(dragLocation)
//Get current value
self.currentPercentage.wrappedValue = Double(self.currentLocation.x / self.sliderWidth)
}.onEnded { _ in
self.onDrag = false
}
private func restrictSliderBtnLocation(_ dragLocation: CGPoint) {
//On Slider Width
if dragLocation.x > CGPoint.zero.x && dragLocation.x < sliderWidth {
calcSliderBtnLocation(dragLocation)
}
}
private func calcSliderBtnLocation(_ dragLocation: CGPoint) {
if dragLocation.y != sliderHeight/2 {
currentLocation = CGPoint(x: dragLocation.x, y: sliderHeight/2)
} else {
currentLocation = dragLocation
}
}
//Current Value
var currentValue: Double {
return sliderValueStart + currentPercentage.wrappedValue * sliderValueRange
}
}
class CustomSlider: ObservableObject {
//Slider Size
let width: CGFloat = 300
let lineWidth: CGFloat = 8
//Slider value range from valueStart to valueEnd
let valueStart: Double
let valueEnd: Double
//Slider Handle
#Published var highHandle: SliderHandle
#Published var lowHandle: SliderHandle
//Handle start percentage (also for starting point)
#SliderValue var highHandleStartPercentage = 1.0
#SliderValue var lowHandleStartPercentage = 0.0
var anyCancellableHigh: AnyCancellable?
var anyCancellableLow: AnyCancellable?
init(start: Double, end: Double) {
valueStart = start
valueEnd = end
highHandle = SliderHandle(sliderWidth: width,
sliderHeight: lineWidth,
sliderValueStart: valueStart,
sliderValueEnd: valueEnd,
startPercentage: _highHandleStartPercentage
)
lowHandle = SliderHandle(sliderWidth: width,
sliderHeight: lineWidth,
sliderValueStart: valueStart,
sliderValueEnd: valueEnd,
startPercentage: _lowHandleStartPercentage
)
anyCancellableHigh = highHandle.objectWillChange.sink { _ in
self.objectWillChange.send()
}
anyCancellableLow = lowHandle.objectWillChange.sink { _ in
self.objectWillChange.send()
}
}
//Percentages between high and low handle
var percentagesBetween: String {
return String(format: "%.2f", highHandle.currentPercentage.wrappedValue - lowHandle.currentPercentage.wrappedValue)
}
//Value between high and low handle
var valueBetween: String {
return String(format: "%.2f", highHandle.currentValue - lowHandle.currentValue)
}
}
Slider implementation:
import SwiftUI
struct ContentView: View {
#ObservedObject var slider = CustomSlider(start: 10, end: 100)
var body: some View {
VStack {
Text("Value: " + slider.valueBetween)
Text("Percentages: " + slider.percentagesBetween)
Text("High Value: \(slider.highHandle.currentValue)")
Text("Low Value: \(slider.lowHandle.currentValue)")
//Slider
SliderView(slider: slider)
}
}
}
struct SliderView: View {
#ObservedObject var slider: CustomSlider
var body: some View {
RoundedRectangle(cornerRadius: slider.lineWidth)
.fill(Color.gray.opacity(0.2))
.frame(width: slider.width, height: slider.lineWidth)
.overlay(
ZStack {
//Path between both handles
SliderPathBetweenView(slider: slider)
//Low Handle
SliderHandleView(handle: slider.lowHandle)
.highPriorityGesture(slider.lowHandle.sliderDragGesture)
//High Handle
SliderHandleView(handle: slider.highHandle)
.highPriorityGesture(slider.highHandle.sliderDragGesture)
}
)
}
}
struct SliderHandleView: View {
#ObservedObject var handle: SliderHandle
var body: some View {
Circle()
.frame(width: handle.diameter, height: handle.diameter)
.foregroundColor(.white)
.shadow(color: Color.black.opacity(0.15), radius: 8, x: 0, y: 0)
.scaleEffect(handle.onDrag ? 1.3 : 1)
.contentShape(Rectangle())
.position(x: handle.currentLocation.x, y: handle.currentLocation.y)
}
}
struct SliderPathBetweenView: View {
#ObservedObject var slider: CustomSlider
var body: some View {
Path { path in
path.move(to: slider.lowHandle.currentLocation)
path.addLine(to: slider.highHandle.currentLocation)
}
.stroke(Color.green, lineWidth: slider.lineWidth)
}
}
This range slider will work with any width provided.
It uses GeometryReader to get the slider width
The slider is bounded by the value range and the thumbs handle cannot cross each other
RangeSliderView Usage
#State var sliderPosition: ClosedRange<Float> = 3...8
RangedSliderView(value: $sliderPosition, bounds: 1...10)
RangeSlideView Implementation
struct RangedSliderView: View {
let currentValue: Binding<ClosedRange<Float>>
let sliderBounds: ClosedRange<Int>
public init(value: Binding<ClosedRange<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
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color.nojaPrimary30)
.frame(height: 4)
ZStack {
let sliderBoundDifference = sliderBounds.count
let stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)
// Calculate Left Thumb initial position
let leftThumbLocation: CGFloat = currentValue.wrappedValue.lowerBound == Float(sliderBounds.lowerBound)
? 0
: CGFloat(currentValue.wrappedValue.lowerBound - Float(sliderBounds.lowerBound)) * stepWidthInPixel
// Calculate right thumb initial position
let rightThumbLocation = CGFloat(currentValue.wrappedValue.upperBound) * stepWidthInPixel
// Path between both handles
lineBetweenThumbs(from: .init(x: leftThumbLocation, y: sliderViewYCenter), to: .init(x: rightThumbLocation, y: sliderViewYCenter))
// Left Thumb Handle
let leftThumbPoint = CGPoint(x: leftThumbLocation, y: sliderViewYCenter)
thumbView(position: leftThumbPoint, value: Float(currentValue.wrappedValue.lowerBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(max(0, dragLocation.x), sliderSize.width)
let newValue = Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel)
// Stop the range thumbs from colliding each other
if newValue < currentValue.wrappedValue.upperBound {
currentValue.wrappedValue = newValue...currentValue.wrappedValue.upperBound
}
})
// Right Thumb Handle
thumbView(position: CGPoint(x: rightThumbLocation, y: sliderViewYCenter), value: currentValue.wrappedValue.upperBound)
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(max(CGFloat(leftThumbLocation), dragLocation.x), sliderSize.width)
var newValue = Float(xThumbOffset / stepWidthInPixel) // convert back the value bound
newValue = min(newValue, Float(sliderBounds.upperBound))
// Stop the range thumbs from colliding each other
if newValue > currentValue.wrappedValue.lowerBound {
currentValue.wrappedValue = currentValue.wrappedValue.lowerBound...newValue
}
})
}
}
}
#ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
Path { path in
path.move(to: from)
path.addLine(to: to)
}.stroke(Color.nojaPrimary, lineWidth: 4)
}
#ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
ZStack {
Text(String(value))
.font(.secondaryFont(weight: .semibold, size: 10))
.offset(y: -20)
Circle()
.frame(width: 24, height: 24)
.foregroundColor(.nojaPrimary)
.shadow(color: Color.black.opacity(0.16), radius: 8, x: 0, y: 2)
.contentShape(Rectangle())
}
.position(x: position.x, y: position.y)
}
}
There are some improvements that can be added
e.g adding a step property or
also implementing the slider with a generic init to support Int, Float and other number types
use ViewBuilder to build a custom label for the slider
I modified the code from #culjo. This code supports preview and the logical parts are moved to viewModel.
import SwiftUI
struct RangeSlider: View {
#ObservedObject var viewModel: ViewModel
#State private var isActive: Bool = false
let sliderPositionChanged: (ClosedRange<Float>) -> Void
var body: some View {
GeometryReader { geometry in
sliderView(sliderSize: geometry.size,
sliderViewYCenter: geometry.size.height / 2)
}
.frame(height: ** insert your height of range slider **)
}
#ViewBuilder private func sliderView(sliderSize: CGSize, sliderViewYCenter: CGFloat) -> some View {
lineBetweenThumbs(from: viewModel.leftThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
to: viewModel.rightThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter))
thumbView(position: viewModel.leftThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
value: Float(viewModel.sliderPosition.lowerBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,
width: sliderSize.width)
if newValue < viewModel.sliderPosition.upperBound {
viewModel.sliderPosition = newValue...viewModel.sliderPosition.upperBound
sliderPositionChanged(viewModel.sliderPosition)
isActive = true
}
})
thumbView(position: viewModel.rightThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
value: Float(viewModel.sliderPosition.upperBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,
width: sliderSize.width)
if newValue > viewModel.sliderPosition.lowerBound {
viewModel.sliderPosition = viewModel.sliderPosition.lowerBound...newValue
sliderPositionChanged(viewModel.sliderPosition)
isActive = true
}
})
}
#ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(** insert your color **)
.frame(height: 4)
Path { path in
path.move(to: from)
path.addLine(to: to)
}
.stroke(isActive ? ** insert your color ** : ** insert your color **,
lineWidth: 4)
}
}
#ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
Circle()
.frame(size: .rangeSliderThumb)
.foregroundColor(isActive ? ** insert your color ** : ** insert your color **)
.contentShape(Rectangle())
.position(x: position.x, y: position.y)
.animation(.spring(), value: isActive)
}
}
extension RangeSlider {
final class ViewModel: ObservableObject {
#Published var sliderPosition: ClosedRange<Float>
let sliderBounds: ClosedRange<Int>
let sliderBoundDifference: Int
init(sliderPosition: ClosedRange<Float>,
sliderBounds: ClosedRange<Int>) {
self.sliderPosition = sliderPosition
self.sliderBounds = sliderBounds
self.sliderBoundDifference = sliderBounds.count - 1
}
func leftThumbLocation(width: CGFloat, sliderViewYCenter: CGFloat = 0) -> CGPoint {
let sliderLeftPosition = CGFloat(sliderPosition.lowerBound - Float(sliderBounds.lowerBound))
return .init(x: sliderLeftPosition * stepWidthInPixel(width: width),
y: sliderViewYCenter)
}
func rightThumbLocation(width: CGFloat, sliderViewYCenter: CGFloat = 0) -> CGPoint {
let sliderRightPosition = CGFloat(sliderPosition.upperBound - Float(sliderBounds.lowerBound))
return .init(x: sliderRightPosition * stepWidthInPixel(width: width),
y: sliderViewYCenter)
}
func newThumbLocation(dragLocation: CGPoint, width: CGFloat) -> Float {
let xThumbOffset = min(max(0, dragLocation.x), width)
return Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel(width: width))
}
private func stepWidthInPixel(width: CGFloat) -> CGFloat {
width / CGFloat(sliderBoundDifference)
}
}
}
struct RangeSlider_Previews: PreviewProvider {
static var previews: some View {
RangeSlider(viewModel: .init(sliderPosition: 2...8,
sliderBounds: 1...10),
sliderPositionChanged: { _ in })
}
}
Is there a way to animate the stroke drawing of a path whenever its destination point changes?
Here is a snippet of my code:
struct JourneyMapLineView: View {
#Binding var rects: [CGRect]
var body: some View {
rects.count != 0 ?
JourneyMapLineShape(startRect: rects[0], endRect: rects[rects.count-1])
.stroke(Color.red), lineWidth: 8)
.animation(.easeInOut(duration: 0.3)) //<-- Is not working
: nil
}
}
struct JourneyMapLineShape: Shape {
var startRect: CGRect
var endRect: CGRect
func path(in _: CGRect) -> Path {
var path = Path()
path.move(to: startRect.origin)
path.addLine(to: endRect.origin)
return path
}
}
Currently, as you can see, there is no animation by changing the value of endRect:
I have already looked at some similar questions, it seems that this is a new case,.
Thank you so much!
Update: Xcode 13.4 / iOS 15.5
The .animation has deprecated, so corrected part is
Route(points: self.vm.points, head: self.vm.lastPoint)
.stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round, lineJoin: .miter, miterLimit: 0, dash: [], dashPhase: 0))
.foregroundColor(.red)
.animation(.linear(duration: 0.5), value: self.vm.lastPoint) // << here !!
Test module is here
Original
Here is a demo of possible solution. Tested with Xcode 11.4 / iOS 13.4
// Route shape animating head point
struct Route: Shape {
var points: [CGPoint]
var head: CGPoint
// make route animatable head position only
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(head.x, head.y) }
set {
head.x = newValue.first
head.y = newValue.second
}
}
func path(in rect: CGRect) -> Path {
Path { path in
guard points.count > 1 else { return }
path.move(to: points.first!)
_ = points.dropFirst().dropLast().map { path.addLine(to: $0) }
path.addLine(to: head)
}
}
}
// Route view model holding all points and notifying when last one changed
class RouteVM: ObservableObject {
var points = [CGPoint.zero] {
didSet {
self.lastPoint = points.last ?? CGPoint.zero
}
}
#Published var lastPoint = CGPoint.zero
}
struct DemoRouteView: View {
#ObservedObject var vm = RouteVM()
var body: some View {
GeometryReader { gp in
ZStack { // area
Rectangle().fill(Color.white)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.vm.points.append(value.location) // read coordinates in area
})
Circle().fill(Color.blue).frame(width: 20)
.position(self.vm.points.first!) // show initial point
// draw route when head changed, animating
Route(points: self.vm.points, head: self.vm.lastPoint)
.stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round, lineJoin: .miter, miterLimit: 0, dash: [], dashPhase: 0))
.foregroundColor(.red)
.animation(.linear(duration: 0.5))
}
.onAppear {
let area = gp.frame(in: .global)
// just initail point at the bottom of screen
self.vm.points = [CGPoint(x: area.midX, y: area.maxY)]
}
}.edgesIgnoringSafeArea(.all)
}
}
How would it be possible to animate Text or TextField views from Swift UI?
By animation I mean, that when the text changes it will "count up".
For example given some label, how can an animation be created that when I set the labels text to "100" it goes up from 0 to 100. I know this was possible in UIKit using layers and CAAnimations, but using the .animation() function in Swift UI and changing the text of a Text or TextField does not seem to do anything in terms of animation.
I've taken a look at Animatable protocol and its related animatableData property but it doesn't seem like Text nor TextField conform to this. I'm trying to create a label that counts up, so given some value, say a Double the changes to that value would be tracked, either using #State or #Binding and then the Text or TextField would animate its content (the actual string text) from what the value was at to what it was set to.
Edit:
To make it clearer, I'd like to recreate a label that looks like this when animated:
There is a pure way of animating text in SwiftUI. Here's an implementation of your progress indicator using the AnimatableModifier protocol in SwiftUI:
I've written an extensive article documenting the use of AnimatableModifier (and its bugs). It includes the progress indicator too. You can read it here: https://swiftui-lab.com/swiftui-animations-part3/
struct ContentView: View {
#State private var percent: CGFloat = 0
var body: some View {
VStack {
Spacer()
Color.clear.overlay(Indicator(pct: self.percent))
Spacer()
HStack(spacing: 10) {
MyButton(label: "0%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 0 } }
MyButton(label: "27%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 0.27 } }
MyButton(label: "100%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 1.0 } }
}
}.navigationBarTitle("Example 10")
}
}
struct Indicator: View {
var pct: CGFloat
var body: some View {
return Circle()
.fill(LinearGradient(gradient: Gradient(colors: [.blue, .purple]), startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 150, height: 150)
.modifier(PercentageIndicator(pct: self.pct))
}
}
struct PercentageIndicator: AnimatableModifier {
var pct: CGFloat = 0
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
content
.overlay(ArcShape(pct: pct).foregroundColor(.red))
.overlay(LabelView(pct: pct))
}
struct ArcShape: Shape {
let pct: CGFloat
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: .degrees(0),
endAngle: .degrees(360.0 * Double(pct)), clockwise: false)
return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
}
}
struct LabelView: View {
let pct: CGFloat
var body: some View {
Text("\(Int(pct * 100)) %")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
}
You can use a CADisplayLink in a BindableObject to create a timer that updates your text during the animation. Gist
class CADisplayLinkBinding: NSObject, BindableObject {
let didChange = PassthroughSubject<CADisplayLinkBinding, Never>()
private(set) var progress: Double = 0.0
private(set) var startTime: CFTimeInterval = 0.0
private(set) var duration: CFTimeInterval = 0.0
private(set) lazy var displayLink: CADisplayLink = {
let link = CADisplayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
link.isPaused = true
return link
}()
func run(for duration: CFTimeInterval) {
let now = CACurrentMediaTime()
self.progress = 0.0
self.startTime = now
self.duration = duration
self.displayLink.isPaused = false
}
#objc private func tick() {
let elapsed = CACurrentMediaTime() - self.startTime
self.progress = min(1.0, elapsed / self.duration)
self.displayLink.isPaused = self.progress >= 1.0
self.didChange.send(self)
}
deinit {
self.displayLink.invalidate()
}
}
And then to use it:
#ObjectBinding var displayLink = CADisplayLinkBinding()
var body: some View {
Text("\(Int(self.displayLink.progress*100))")
.onAppear {
self.displayLink.run(for: 10.0)
}
}