Skip to content

Commit 8b50dec

Browse files
feat: implement RatingControl & NumericRatingController (#146)
1 parent 7c49076 commit 8b50dec

File tree

2 files changed

+249
-0
lines changed

2 files changed

+249
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//
2+
// NumericRatingController.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 04/11/2020.
6+
//
7+
8+
#if !InstantSearchCocoaPods
9+
import InstantSearchCore
10+
#endif
11+
#if canImport(UIKit) && (os(iOS) || os(macOS))
12+
import UIKit
13+
14+
public class NumericRatingController {
15+
16+
public let ratingControl: RatingControl
17+
18+
public init(ratingControl: RatingControl = .init()) {
19+
self.ratingControl = ratingControl
20+
}
21+
22+
}
23+
24+
extension NumericRatingController: NumberController {
25+
26+
public func setItem(_ item: Double) {
27+
ratingControl.value = item
28+
}
29+
30+
public func setComputation(computation: Computation<Double>) {
31+
32+
}
33+
34+
public func setBounds(bounds: ClosedRange<Double>?) {
35+
if let upperBound = bounds?.upperBound {
36+
ratingControl.maximumValue = Int(upperBound)
37+
}
38+
}
39+
40+
}
41+
42+
#endif
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//
2+
// RatingControl.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 05/11/2020.
6+
//
7+
8+
import Foundation
9+
#if canImport(UIKit) && (os(iOS) || os(macOS) || os(tvOS))
10+
import UIKit
11+
12+
/// A control for setting a rating value.
13+
///
14+
/// The rating is represented as multiple points. The maximumValue defines how many points the control renders.
15+
/// The images used for setup of one point can be set via **emptyImage**, **partialImage** and **fullImage** properties.
16+
///
17+
/// # Rendering rules
18+
/// The point is rendered as *empty*, if the difference between the integer part of rating value and the point index is negative.
19+
///
20+
/// - Example: for rating 3.5, its integer part is 3, so the 5-th point (index 4) is rendered as empty
21+
///
22+
/// The point is rendered as *full*, if the difference between the integer part of rating value and the point index is greater than 1.
23+
///
24+
/// - Example: for rating 3.5, its integer part is 3, so the 3-rd point (index 2) is rendered as full
25+
///
26+
/// The point is rendered as *partial*, of the if the difference between the integer part of rating value and the point index is equal to 0 and the fractional part of rating value is greater or equal than 0.5. Otherwise it is rendered as empty.
27+
///
28+
/// - Example: for rating 3.5 its integer part is 3, the fractional part is 0.5, so the 4-th point (index 3) is rendered as partial while for rating 3.4, the fractional part is 0.4, so the 4-th point is rendered as empty.
29+
30+
public class RatingControl: UIControl {
31+
32+
/// The numeric value of the rating control.
33+
///
34+
/// The default value for this property is 0.
35+
public var value: Double = 0 {
36+
didSet {
37+
refresh()
38+
sendActions(for: .valueChanged)
39+
}
40+
}
41+
42+
/// The highest possible numeric value for the rating control.
43+
///
44+
/// The default value for this property is 5.
45+
public var maximumValue: Int = 5 {
46+
didSet {
47+
setupPoints()
48+
}
49+
}
50+
51+
/// Whether the rating value can be changed by pan gesture
52+
///
53+
/// The default value for this property is true.
54+
public var isPanGestureEnabled: Bool = true {
55+
didSet {
56+
panGestureRecognizer.isEnabled = isPanGestureEnabled
57+
}
58+
}
59+
60+
/// The empty point image
61+
///
62+
/// Default value is nil
63+
public var emptyImage: UIImage?
64+
65+
/// The partial point image
66+
///
67+
/// Default value is nil
68+
public var partialImage: UIImage?
69+
70+
/// The full point image
71+
///
72+
/// Default value is nil
73+
public var fullImage: UIImage?
74+
75+
private let pointsStackView = UIStackView()
76+
77+
private let panGestureRecognizer: UIPanGestureRecognizer
78+
79+
public init() {
80+
self.panGestureRecognizer = UIPanGestureRecognizer()
81+
super.init(frame: .zero)
82+
setupView()
83+
if #available(iOS 13.0, tvOS 13.0, *) {
84+
emptyImage = UIImage(systemName: "star")
85+
partialImage = UIImage(systemName: "star.leadinghalf.fill")
86+
fullImage = UIImage(systemName: "star.fill")
87+
}
88+
}
89+
90+
required init?(coder: NSCoder) {
91+
fatalError("init(coder:) has not been implemented")
92+
}
93+
94+
@objc private func didPan(_ panGestureRecognizer: UIPanGestureRecognizer) {
95+
let touchCoordinateX = panGestureRecognizer.location(in: pointsStackView).x
96+
guard touchCoordinateX > 0 else {
97+
return
98+
}
99+
let capturedValue = Double(touchCoordinateX/pointsStackView.bounds.width) * Double(maximumValue)
100+
101+
let (integerPart, fractionalPart) = extract(from: capturedValue)
102+
let formattedValue = Double(integerPart) + Double(fractionalPart)/10
103+
if formattedValue < 0 {
104+
value = 0
105+
} else if formattedValue > Double(maximumValue) {
106+
value = Double(maximumValue)
107+
} else {
108+
value = formattedValue
109+
}
110+
}
111+
112+
@objc private func didtap(_ tapGestureRecognizer: UITapGestureRecognizer) {
113+
guard
114+
let tappedImageView = tapGestureRecognizer.view,
115+
let tappedImageViewIndex = pointsStackView.arrangedSubviews.firstIndex(of: tappedImageView) else { return }
116+
value = Double(tappedImageViewIndex + 1)
117+
}
118+
119+
}
120+
121+
private extension RatingControl {
122+
123+
enum RatingPointState {
124+
case empty, partial, full
125+
}
126+
127+
func setupView() {
128+
pointsStackView.translatesAutoresizingMaskIntoConstraints = false
129+
pointsStackView.distribution = .equalCentering
130+
pointsStackView.alignment = .center
131+
addSubview(pointsStackView)
132+
NSLayoutConstraint.activate([
133+
pointsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
134+
pointsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
135+
pointsStackView.topAnchor.constraint(equalTo: topAnchor),
136+
pointsStackView.bottomAnchor.constraint(equalTo: bottomAnchor)
137+
])
138+
panGestureRecognizer.addTarget(self, action: #selector(didPan(_:)))
139+
pointsStackView.addGestureRecognizer(panGestureRecognizer)
140+
setupPoints()
141+
}
142+
143+
func setupPoints() {
144+
let pointsDiffCount = maximumValue - pointsStackView.arrangedSubviews.count
145+
guard pointsDiffCount != 0 else { return }
146+
if pointsDiffCount < 0 {
147+
for pointView in pointsStackView.arrangedSubviews.suffix(Int(pointsDiffCount.magnitude)) {
148+
pointsStackView.removeArrangedSubview(pointView)
149+
pointsStackView.removeFromSuperview()
150+
}
151+
} else {
152+
for _ in 0..<pointsDiffCount {
153+
let imageView = UIImageView()
154+
imageView.translatesAutoresizingMaskIntoConstraints = false
155+
imageView.isUserInteractionEnabled = true
156+
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didtap(_:))))
157+
pointsStackView.addArrangedSubview(imageView)
158+
}
159+
}
160+
}
161+
162+
func image(for state: RatingPointState) -> UIImage? {
163+
switch state {
164+
case .empty:
165+
return emptyImage
166+
case .partial:
167+
return partialImage
168+
case .full:
169+
return fullImage
170+
}
171+
}
172+
173+
func refresh() {
174+
let pointsCount = Int(maximumValue)
175+
let imageStates = (0..<pointsCount).map { stateOfPoint(withIndex: $0, for: value) }.map(image(for:))
176+
let imageViews = pointsStackView.arrangedSubviews.compactMap { $0 as? UIImageView }
177+
zip(imageStates, imageViews).forEach { image, imageView in imageView.image = image }
178+
}
179+
180+
func fractionalString(for value: Double, fractionDigits: Int) -> String {
181+
let formatter = NumberFormatter()
182+
formatter.minimumFractionDigits = fractionDigits
183+
formatter.maximumFractionDigits = fractionDigits
184+
return formatter.string(from: value as NSNumber) ?? "\(self)"
185+
}
186+
187+
func extract(from value: Double) -> (integer: Int, fractional: Int) {
188+
let strings = fractionalString(for: value, fractionDigits: 1).split(separator: ".").map(String.init)
189+
let integerPart = Int(strings.first!)!
190+
let fractionalPart = Int(strings.last!)!
191+
return (integerPart, fractionalPart)
192+
}
193+
194+
func stateOfPoint(withIndex pointIndex: Int, for value: Double) -> RatingPointState {
195+
let (integerPart, fractionalPart) = extract(from: value)
196+
switch integerPart - pointIndex {
197+
case ..<0:
198+
return .empty
199+
case 1...:
200+
return .full
201+
default:
202+
return (0..<5).contains(fractionalPart) ? .empty : .partial
203+
}
204+
}
205+
206+
}
207+
#endif

0 commit comments

Comments
 (0)