mirror of
https://github.com/divkit/divkit.git
synced 2026-06-06 20:07:59 +00:00
Slider fixes
commit_hash:789b64c13e1e7dc2091ce7f7b2f408ff3b83c40a
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user