From 2587ee95686f191d2e4fbaa95f0c1947b2d83eee Mon Sep 17 00:00:00 2001 From: denlvovich Date: Fri, 20 Feb 2026 12:07:16 +0300 Subject: [PATCH] Slider fixes commit_hash:789b64c13e1e7dc2091ce7f7b2f408ff3b83c40a --- .mapping.json | 1 + .../Blocks/Slider/MarksConfiguration.swift | 4 +- .../LayoutKit/Blocks/Slider/MarksLayer.swift | 24 ++++- .../LayoutKit/Blocks/Slider/SliderModel.swift | 26 ++++- .../LayoutKit/Blocks/Slider/SliderView.swift | 50 ++++++--- .../ViewModels/SliderModelTests.swift | 102 ++++++++++++++++++ 6 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift diff --git a/.mapping.json b/.mapping.json index 01b42435a..7830f9cfa 100644 --- a/.mapping.json +++ b/.mapping.json @@ -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", diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksConfiguration.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksConfiguration.swift index a7cae39b2..abea9da91 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksConfiguration.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksConfiguration.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 } diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksLayer.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksLayer.swift index 776bd0d8c..dbc79e2bb 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksLayer.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/MarksLayer.swift @@ -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 + ) } } diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderModel.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderModel.swift index e45fb3303..3e08644a3 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderModel.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderModel.swift @@ -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) + } } diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderView.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderView.swift index 24ef49c9e..3607fa151 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderView.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Slider/SliderView.swift @@ -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 diff --git a/client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift b/client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift new file mode 100644 index 000000000..57506fa56 --- /dev/null +++ b/client/ios/LayoutKit/LayoutKitTests/ViewModels/SliderModelTests.swift @@ -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 + ) +}