Animating Text in Swift UI - ios

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

Related

How to add other views to UIViewRepresentable?

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?

Rotating a gradient based off device pitch, roll, and yaw - creating a hologram / security sticker effect

I'm trying to implement a little fun addition into an app I'm working on, and can't seem to figure out how to get the animation to work completely / correctly.
At the moment I have something that looks like this: https://i.imgur.com/95O1Wul.mp4
What I'm trying to achieve
I'm trying to create a hologram like you would see on some shiny game cards or in some bank notes or on those VOID stickers.
I'm using the x position of the device to determine whether to shift the start and end point of the gradient. I'm trying to figure out in my head how to use these values to make the gradient's start and end points shift based on the values. And if I incorporated the other axes how it would move around if the device is rotated.
As for code, at the moment I have:
View Model
final class ContentViewModel: ObservableObject {
#Published var gyroRotation = CMRotationRate()
private let manager = CMMotionManager()
private var timer: Timer?
func startGyroscope() {
if(manager.isGyroAvailable) {
manager.gyroUpdateInterval = 1.0
manager.startGyroUpdates()
self.timer = Timer(fire: Date(), interval: (1.0/60.0), repeats: true, block: { timer in
if let data = self.manager.gyroData {
self.gyroRotation = data.rotationRate
}
})
RunLoop.current.add(self.timer!, forMode: .default)
}
}
func stopGyroscope() {
if(self.timer != nil) {
self.timer?.invalidate()
self.timer = nil
self.manager.stopGyroUpdates()
}
}
}
View
struct ContentView: View {
#StateObject var cm = ContentViewModel()
var rotation: CMRotationRate { cm.gyroRotation }
let gridItems = [
GridItem(.flexible(minimum: 40, maximum: 90)),
GridItem(.flexible(minimum: 40, maximum: 90)),
GridItem(.flexible(minimum: 40, maximum: 90)),
GridItem(.flexible(minimum: 40, maximum: 90)),
GridItem(.flexible(minimum: 40, maximum: 90))
]
var body: some View {
LazyVGrid(columns: gridItems, spacing: 30) {
ForEach(0..<60) { _ in
LinearGradient(
colors: [.red, .orange, .yellow, .blue, .purple, .green],
startPoint: rotation.x > 0 ? .leading : .trailing,
endPoint: rotation.x > 0 ? .trailing : .leading
)
.animation(.linear, value: rotation.x)
.frame(width: 50, height: 50)
.mask(
Image("logo")
.resizable()
.scaledToFit()
.opacity(0.3)
)
}
}
.padding(20)
.onAppear { cm.startGyroscope() }
}
}
Continuing on this to make it work, and I realised I didn't need the LinearGradient to animate but the Color itself:
View
struct CoreMotion: View {
// -- where the data lives
#ObservedObject var coreMotionVM = CoreMotionViewModel()
let gridItems = [
GridItem(), GridItem(), GridItem(), GridItem()
]
// -- the view
var body: some View {
LazyVGrid(columns: gridItems, spacing: 5) {
ForEach( 0 ..< 60 ) { _ in
coreMotionVM.tintColour
.animation(
.easeInOut,
value: coreMotionVM.roll
)
.frame(width: 60, height: 60)
.padding(.vertical)
.mask(
Image("logo")
.resizable()
.scaledToFit()
.opacity(0.2)
)
}
}
.padding(.horizontal)
.padding(.top)
}
}
ViewModel
final class CoreMotionViewModel: ObservableObject {
// -- what we share
#Published var roll: Double = 0.0
// -- core motion manager
private var manager: CMMotionManager
// -- initialisation
init() {
// -- create the instance
self.manager = CMMotionManager()
// -- how often to get the data
self.manager.deviceMotionUpdateInterval = 1/60
// -- start it on the main thread
self.manager.startDeviceMotionUpdates(to: .main) { motionData, error in
// -- error lets get out of here
guard error == nil else {
print(error!)
return
}
// -- update the data
if let motionData = motionData {
self.roll = motionData.attitude.roll
}
}
}
// cycle to the next point
var tintColour: Color {
switch roll {
case ..<(-1.5):
return .red
case -1.5 ..< -1.0 :
return .orange
case -1.0 ..< -0.5 :
return .yellow
case -0.5 ..< 0.5 :
return .green
case 0.5 ..< 1.0 :
return .blue
case 1.0... :
return .purple
default:
return .pink
}
}
}

How to force update to render continuous path

i'm currently working on continuous animation on path. The Path should be updated with new values periodically and update should trigger start of new animation with those values. I'm having a problem while trying to refresh view with new values. The animation with path is shown at start with animation and after trying to trigger view update from method it's not showing any new view or updated path. Just white screen. My code for this view and updating is:
The chart view wrapped into UIViewRepresentable:
struct AnimatedChartView: UIViewRepresentable {
let geometryProxy: GeometryProxy
let leadValues: [Float]//Binding<[Float]>
//Chart control values
//step coefficinet
let xStepCoefficient: CGFloat = 0.00035
//Single step on view
var xStep: CGFloat{
let xStepValue = xStepCoefficient * geometryProxy.frame(in: .local).width
return xStepValue
}
var rootView: UIView
var chartLayer: CAShapeLayer
//Make chart view with animation
func makeUIView(context: Context) -> some UIView {
//Prepare view for animation and chart
chartLayer.delegate = context.coordinator
rootView.layer.addSublayer(chartLayer)
return rootView
}
init(geometry: GeometryProxy, values: [Float]) {
rootView = UIView()
geometryProxy = geometry
leadValues = values
chartLayer = CAShapeLayer()
chartLayer = prepareAnimationLayer()
}
//Update viewu
func updateUIView(_ uiView: UIViewType, context: Context) {
print("Update uiView")
DispatchQueue.main.async {
let newChartLayer = prepareAnimationLayer()
rootView.layer.addSublayer(newChartLayer)
rootView.setNeedsDisplay()
}
}
//Assign
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
//Delegates from view
class Coordinator: NSObject, CALayerDelegate {
var parent: AnimatedChartView
init(_ parentView: AnimatedChartView) {
self.parent = parentView
}
func draw(_ layer: CALayer, in ctx: CGContext) {
print("Drawing layer in context")
}
func layerWillDraw(_ layer: CALayer) {
print("layer will draw")
}
}
//Assign animation layer for chart
private func prepareAnimationLayer() -> CAShapeLayer {
//Create animation proeprties
let chartLayer = CAShapeLayer()
let strokeAnimation = makeAnimation()
let chartPath = makeChartFromValues(newValues: leadValues)
//Assign path and marks start and end of stroke to animate
chartLayer.path = chartPath.cgPath
chartLayer.strokeStart = 0.0
chartLayer.strokeEnd = 0.0
chartLayer.strokeColor = UIColor.green.cgColor
chartLayer.lineWidth = 2
chartLayer.fillColor = UIColor.clear.cgColor
chartLayer.add(strokeAnimation, forKey: "strokeAnimation")
return chartLayer
}
//Setup chart animation
private func makeAnimation() -> CABasicAnimation{
let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
//Prepare animation
strokeEndAnimation.fromValue = 0.0
strokeEndAnimation.toValue = 1.0
strokeEndAnimation.duration = 1
strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
strokeEndAnimation.repeatCount = 0
strokeEndAnimation.duration = 1
return strokeEndAnimation
}
//makes chart path
private func makeChartFromValues(newValues: [Float]) -> UIBezierPath{
let chartPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: .init(width: 200, height: 120)), byRoundingCorners: .allCorners, cornerRadii: .zero)
chartPath.move(to: .zero)
var currentX = xStep
newValues.forEach{ value in
//Value point to draw
let nextPoint = CGPoint(x: currentX, y: CGFloat(value / 1000))
//Adds line to chart
chartPath.addLine(to:nextPoint)
//Move to next value
currentX += xStep
}
return chartPath
}
}
and view function with viewmodel with timer which updates the values
struct UIKitAnimatedPathView: View {
#State var animationRect: CGRect = CGRect(origin: .zero, size: CGSize(width: 200, height: 200))
#StateObject var viewModel = AnimationViewModel()
var body: some View {
GeometryReader{ geo in
VStack(content: {
ForEach(0 ..< 18, id: \.self){ i in
AnimatedChartView(geometry: geo, values: viewModel.values)
}
})
}
}
}
Viewmodel:
class AnimationViewModel: ObservableObject{
#Published var values: [Float] = [Float](repeating: 1, count: 2000)
#Published var timer: Timer?
init() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: {time in
//print("time in: \(time.timeInterval)")
var vals:[Float] = []
for _ in 0 ..< 2000{
let rand = Float.random(in: -20000 ..< 0)
vals.append(rand)
}
self.values.removeAll()
self.values = vals
})
}
}
You can see commented out some part of code as i was trying to make it work somehow but with no luck so far... The functionality i want to achive is continuously updating chart. This code works for first run. Thank you in advance for any guidence on how to make it working :))

SwiftUI - 2 Handle Range Slider

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

SwiftUI DragGestures not updating until different DragGesture triggered

this gif should pretty clearly show my issue.
In words, I have a DragGesture for the background color, and TapGesture to add Text() views. Each Text View has a DragGesture on it to update its location as its dragged.
This works, but the visually the Text Views position don't update until I activate the background color DragGesture. I've confirmed that the Text Views position values update, they just don't visually update.
Also, I have a long press gesture which 'locks' the background color by preventing a call to moodColor(). When this is locked, the Text Views will not move until I perform another long press to unlock it, and then drag the background color around.
I have a feeling I'm misusing DragGestures, but it seemed obvious that each view should have it's own gesture.
Here is the code.
class Note : Identifiable{
var id = UUID()
var text: Text
var dragOffset : CGPoint
#GestureState private var location: CGPoint = .zero
var drag : some Gesture{ DragGesture(minimumDistance: 0.5, coordinateSpace: .global).onChanged{ value in self.dragOffset = value.location }
.updating($location){ (value, state, transaction) in state = value.location }
}
init(textVal: String){
self.text = Text(textVal).font(.largeTitle)
self.dragOffset = CGPoint(x: 50, y: 50)
}
}
struct Col{
var red: Double = 1.0
var green: Double = 1.0
var blue: Double = 1.0
}
struct ContentView: View {
#State var brightness = 0.9
#State var color = Col() // Color Grid Style, RGB
#GestureState var dragInfo = CGSize.zero
#State private var myviews: [Note] = []
#State private var colorLocked = false
var body: some View {
return GeometryReader { geo in
Color.init( red: self.color.red * self.brightness, green: self.color.green * self.brightness, blue: self.color.blue * self.brightness)
.edgesIgnoringSafeArea(.all)
.gesture( TapGesture().onEnded{ value in self.myviews.append(Note(textVal: "A Note")) })
.gesture( LongPressGesture(minimumDuration: 0.5).onEnded{
_ in UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
self.colorLocked = !self.colorLocked })
.gesture( DragGesture().onChanged{
if !self.colorLocked { self.moodColor(data: $0, width: geo.size.width, height: geo.size.height) }})
// moodColor() does math to change the background
.gesture( MagnificationGesture().onChanged{
self.brightness = Double($0.magnitude + 0.5)
})
ForEach(self.myviews) { note in
note.text.gesture(note.drag).position(note.dragOffset) }}
}
Keep your Views is Views not in Model class
import SwiftUI
class Note : Identifiable{
var id = UUID()
var dragOffset : CGSize
var textVal: String
init(textVal: String){
self.textVal = textVal
self.dragOffset = .zero
}
}
struct Col{
var red: Double = 1.0
var green: Double = 1.0
var blue: Double = 1.0
}
struct AnimatedText: View {
var note: Note
#GestureState private var location: CGPoint = .zero
#State private var offset: CGSize = .zero
var drag : some Gesture{ DragGesture()
.onChanged {
self.offset.height = self.note.dragOffset.height + $0.translation.height
self.offset.width = self.note.dragOffset.width + $0.translation.width
}
.onEnded { _ in
self.note.dragOffset = self.offset//self.offset = .zero
}
}
var body: some View {
Text(note.textVal)
.font(.largeTitle)
.offset(x: offset.width, y: offset.height)
.gesture(drag)
}
}
struct DragGestureNotMoving: View {
#State var brightness = 0.9
#State var color = Col() // Color Grid Style, RGB
#GestureState var dragInfo = CGSize.zero
#State private var myNotes: [Note] = []
#State private var colorLocked = false
var body: some View {
return GeometryReader { geo in
Color.init( red: self.color.red * self.brightness, green: self.color.green * self.brightness, blue: self.color.blue * self.brightness)
.edgesIgnoringSafeArea(.all)
.gesture( TapGesture().onEnded{ value in self.myNotes.append(Note(textVal: "A Note")) })
.gesture( LongPressGesture(minimumDuration: 0.5).onEnded{
_ in UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
self.colorLocked = !self.colorLocked })
//.gesture( DragGesture().onChanged{_ in if !self.colorLocked {self.moodColor(data: $0, width: geo.size.width, height: geo.size.height) }})// moodColor() does math to change the background
.gesture( MagnificationGesture().onChanged{
self.brightness = Double($0.magnitude + 0.5)
})
ForEach(self.myNotes) { note in
AnimatedText(note: note)
}
}
}
}
I commented the moodColor() since I don't have the extra code but the movement works with the changes. Look into SimultaneosGesture if you are having other issues.
https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/coordinating_multiple_gesture_recognizers/allowing_the_simultaneous_recognition_of_multiple_gestures

Resources