Slider fixes

commit_hash:789b64c13e1e7dc2091ce7f7b2f408ff3b83c40a
This commit is contained in:
denlvovich
2026-02-20 12:27:59 +03:00
parent 05f2999ba5
commit 2587ee9568
6 changed files with 185 additions and 22 deletions
+1
View File
@@ -17459,6 +17459,7 @@
"client/ios/LayoutKit/LayoutKitTests/ViewModels/PageIndicatorStateTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/PageIndicatorStateTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/PagerViewLayoutTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/PagerViewLayoutTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/PhoneMaskFormatterTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/PhoneMaskFormatterTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/TabbedPages/TabContentsViewModelTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/TabbedPages/TabContentsViewModelTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/TabbedPages/TabInterimItemExtensionsTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/TabbedPages/TabInterimItemExtensionsTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/VisibilityActionPerformer/VisibilityActionPerformersTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/VisibilityActionPerformer/VisibilityActionPerformersTests.swift",
@@ -4,9 +4,11 @@ import VGSL
public struct MarksConfiguration: Equatable {
public static let empty = MarksConfiguration(
modelConfiguration: .empty,
horizontalInset: 0
horizontalInset: 0,
marksStep: 1
)
let modelConfiguration: MarksConfigurationModel
let horizontalInset: CGFloat
let marksStep: CGFloat
}
@@ -22,6 +22,10 @@ final class MarksLayer: CALayer {
configuration.modelConfiguration.inactiveMark
}
private var marksStep: CGFloat {
configuration.marksStep
}
override init() {
super.init()
setProperties()
@@ -69,7 +73,7 @@ final class MarksLayer: CALayer {
} else {
makeMarks(
from: 0,
to: leftThumb - 1 - minValue,
to: leftThumb - marksStep - minValue,
style: .inactive,
in: ctx
)
@@ -81,7 +85,7 @@ final class MarksLayer: CALayer {
)
}
makeMarks(
from: rightThumb + 1 - minValue,
from: rightThumb + marksStep - minValue,
to: maxValue - minValue,
style: .inactive,
in: ctx
@@ -94,7 +98,9 @@ final class MarksLayer: CALayer {
style: MarkStyle,
in ctx: CGContext
) {
let spaceWidth = (bounds.width - configuration.horizontalInset) / (maxValue - minValue)
let range = maxValue - minValue
let maxWidth = bounds.width - configuration.horizontalInset
let spaceWidth = maxWidth / range * marksStep
let markConfiguration = markConfiguration(for: style)
guard markConfiguration != .empty,
@@ -103,15 +109,23 @@ final class MarksLayer: CALayer {
return
}
let startIndex = startIndex / marksStep
let endIndex = endIndex / marksStep
let markHeight = markConfiguration.size.height
let xOrigin = spaceWidth * startIndex
.floored() + (configuration.horizontalInset - markConfiguration.size.width) / 2
let yOrigin = bounds.midY - markHeight / 2
var origin = CGPoint(x: xOrigin, y: yOrigin)
for _ in Int(startIndex - minValue)...Int(endIndex - minValue) {
let steps = Int(ceil(endIndex - startIndex))
for _ in 0...steps {
markConfiguration.render(in: ctx, with: origin)
origin.x += spaceWidth
origin.x = min(
origin.x + spaceWidth,
maxWidth + (configuration.horizontalInset - markConfiguration.size.width) / 2
)
}
}
@@ -92,6 +92,8 @@ public struct SliderModel: Equatable {
}
}
static let marksCountLimit: CGFloat = 1000
static var empty: Self {
SliderModel(
firstThumb: .empty,
@@ -120,7 +122,8 @@ public struct SliderModel: Equatable {
public var marksConfiguration: MarksConfiguration {
MarksConfiguration(
modelConfiguration: marksModelConfiguration,
horizontalInset: horizontalInset
horizontalInset: horizontalInset,
marksStep: stepSize
)
}
@@ -167,6 +170,10 @@ public struct SliderModel: Equatable {
)
}
var stepSize: CGFloat {
ceil((CGFloat(valueRange) + 1) / Self.marksCountLimit)
}
public init(
firstThumb: ThumbModel,
secondThumb: ThumbModel? = nil,
@@ -199,4 +206,21 @@ public struct SliderModel: Equatable {
lhs.path == rhs.path &&
lhs.isEnabled == rhs.isEnabled
}
func nearestValue(_ currentValue: CGFloat) -> CGFloat {
if stepSize == 1 {
return currentValue.rounded(.toNearestOrAwayFromZero)
} else {
let minValue = nearestMinValue(currentValue)
let maxValue = min(minValue + stepSize, CGFloat(maxValue))
return (currentValue - minValue) > (maxValue - currentValue) ? maxValue : minValue
}
}
func nearestMinValue(_ currentValue: CGFloat) -> CGFloat {
let offset = currentValue - CGFloat(minValue)
let stepFloor = CGFloat(Int(offset / stepSize)) * stepSize
return min(CGFloat(maxValue), CGFloat(minValue) + stepFloor)
}
}
@@ -6,6 +6,7 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
var layoutReporter: LayoutReporter?
private var sliderModel: SliderModel = .empty
private var configuredPath: UIElementPath?
private var thumbAnimator: UIViewPropertyAnimator?
private var firstThumbProgress: CGFloat = .zero {
didSet {
@@ -41,10 +42,10 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
abs(sliderModel.firstThumb.offsetX),
abs(sliderModel.secondThumb?.offsetX ?? 0)
)
if sliderModel.valueRange == 0 {
return width
return if sliderModel.valueRange == 0 {
width
} else {
return CGFloat(width) / CGFloat(sliderModel.valueRange)
CGFloat(width) / CGFloat(sliderModel.valueRange)
}
}
@@ -175,8 +176,16 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
sliderModel: sliderModel
))
self.sliderModel.firstThumb.value.value = clampedFirstThumbValue
self.sliderModel.secondThumb?.value.value = clampedSecondThumbValue
if configuredPath != sliderModel.path {
self.sliderModel.firstThumb.value
.value = Int(sliderModel.nearestValue(CGFloat(clampedFirstThumbValue)))
self.sliderModel.secondThumb?.value
.value = Int(sliderModel.nearestValue(CGFloat(clampedSecondThumbValue)))
configuredPath = sliderModel.path
} else {
self.sliderModel.firstThumb.value.value = clampedFirstThumbValue
self.sliderModel.secondThumb?.value.value = clampedSecondThumbValue
}
if recognizer.state != .began, recognizer.state != .changed {
firstThumbProgress = CGFloat(clampedFirstThumbValue)
@@ -200,7 +209,8 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
if let thumbValue = updatedThumbsValue(
oldValue: firstThumbProgress,
newValue: value,
thumbPosition: value < secondThumbProgress ? .left : .right
thumbPosition: value < secondThumbProgress ? .left : .right,
model: sliderModel
) {
sliderModel.firstThumb.value.value = thumbValue
setNeedsLayout()
@@ -213,7 +223,8 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
let thumbValue = updatedThumbsValue(
oldValue: secondThumbProgress,
newValue: value,
thumbPosition: value < firstThumbProgress ? .left : .right
thumbPosition: value < firstThumbProgress ? .left : .right,
model: sliderModel
) {
sliderModel.secondThumb?.value.value = thumbValue
setNeedsLayout()
@@ -276,10 +287,13 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
case .cancelled, .ended, .failed, .possible:
thumbAnimator?.stopAnimation(true)
animateActiveThumb(
to: currentValue.rounded(.toNearestOrAwayFromZero),
to: sliderModel.nearestValue(currentValue),
from: currentValue
)
updateProgress(currentValue.rounded(.toNearestOrAwayFromZero))
updateProgress(
sliderModel.nearestValue(currentValue)
)
layoutIfNeeded()
@unknown default: break
}
@@ -289,8 +303,9 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
to newValue: Double,
from oldValue: Double
) {
let normalizedDelta = abs(newValue - oldValue) / Double(max(1, sliderModel.valueRange))
thumbAnimator = .runningPropertyAnimator(
withDuration: animationDuration * 2 * abs(newValue - oldValue),
withDuration: animationDuration * 2 * normalizedDelta,
delay: 0,
options: [.allowUserInteraction, .curveEaseIn],
animations: { [self] in
@@ -440,17 +455,21 @@ final class SliderView: BlockView, VisibleBoundsTrackingLeaf {
private func updatedThumbsValue(
oldValue: CGFloat,
newValue: CGFloat,
thumbPosition: ThumbPosition
thumbPosition: ThumbPosition,
model: SliderModel
) -> Int? {
guard oldValue != newValue else {
return nil
}
let newValueIntegerPart = Int(newValue.rounded(.down))
if newValue.isApproximatelyEqualTo(newValue.rounded(.toNearestOrAwayFromZero)) {
return Int(newValue.rounded(.toNearestOrAwayFromZero))
let nearestValue = model.nearestValue(newValue)
if newValue.isApproximatelyEqualTo(nearestValue) {
return Int(nearestValue)
}
return thumbPosition == .right ? newValueIntegerPart : newValueIntegerPart + 1
let nearestMinValue = model.nearestMinValue(newValue)
return thumbPosition == .right ? Int(nearestMinValue) : Int(nearestMinValue + model.stepSize)
}
private enum ThumbPosition {
@@ -489,4 +508,5 @@ extension UIView {
layer.mask = shapeLayer
}
}
#endif
@@ -0,0 +1,102 @@
import Foundation
@testable import LayoutKit
import Testing
@Suite
struct SliderModelTests {
@Test(
arguments: [
(0, 999, 1),
(0, 1000, 2),
(0, 2000, 3),
(0, 3000, 4),
(-1000, 0, 2),
(-500, 500, 2),
]
)
func stepSize(minValue: Int, maxValue: Int, expectedStepSize: CGFloat) {
let model = makeSliderModel(minValue: minValue, maxValue: maxValue)
#expect(model.stepSize == expectedStepSize)
}
@Test(
arguments: [
(0, 100, 1, 0, 0),
(0, 100, 1, 100, 100),
(0, 2000, 3, 0, 0),
(0, 2000, 3, 2.9, 0),
(0, 2000, 3, 3, 3),
(0, 2000, 3, 6, 6),
(0, 2000, 3, 2000, 1998),
(0, 2000, 3, 2001, 2000),
(-100, 0, 1, -100, -100),
(-100, 0, 1, -50.5, -51),
(-100, 0, 1, 0, 0),
(-1000, 0, 2, -500, -500),
(-1000, 0, 2, -501, -502),
]
)
func nearestMinValue(
minValue: Int,
maxValue: Int,
stepSize: CGFloat,
currentValue: CGFloat,
expected: CGFloat
) {
let model = makeSliderModel(minValue: minValue, maxValue: maxValue)
#expect(model.stepSize == stepSize)
let result = model.nearestMinValue(currentValue)
#expect(result == expected)
}
@Test(
arguments: [
(0, 100, 1, 0, 0),
(0, 100, 1, 50.5, 51),
(0, 100, 1, 100, 100),
(0, 2000, 3, 3, 3),
(0, 2000, 3, 4.5, 3),
(0, 2000, 3, 5.5, 6),
(0, 2000, 3, 1999.5, 2000),
(0, 2000, 3, 2000, 2000),
(-100, 0, 1, -50.5, -51),
(-100, 0, 1, -50.4, -50),
(-100, 0, 1, 0, 0),
(-1000, 0, 2, -501, -502),
(-1000, 0, 2, -500.5, -500),
]
)
func nearestValue(
minValue: Int,
maxValue: Int,
stepSize: CGFloat,
currentValue: CGFloat,
expected: CGFloat
) {
let model = makeSliderModel(minValue: minValue, maxValue: maxValue)
#expect(model.stepSize == stepSize)
let result = model.nearestValue(currentValue)
#expect(result == expected)
}
}
private func makeSliderModel(minValue: Int, maxValue: Int) -> SliderModel {
let marksConfig = MarksConfigurationModel(
minValue: CGFloat(minValue),
maxValue: CGFloat(maxValue),
activeMark: .empty,
inactiveMark: .empty,
layoutDirection: .leftToRight
)
return SliderModel(
firstThumb: .empty,
secondThumb: nil,
minValue: minValue,
maxValue: maxValue,
marksConfiguration: marksConfig,
ranges: [],
layoutDirection: .leftToRight,
isEnabled: true
)
}