SwiftUI DragGestures not updating until different DragGesture triggered - ios

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

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

SwiftUI - Drag and drop circles

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

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

Animating Text in Swift UI

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

Resources