Реализация кастомного UI-элемента для выбора времени. Часть 2
Вращение циферблата
Здесь я описываю математику вращения, которую я использовал по совету из доклада, озвученного на MBLTdev.
Основной координатной составляющей у нас служит contentOffset
у scrollView
. На этой основе мы считаем угол поворота.
Введем несколько свойств, которые будут хранить информацию о текущем положении крутилки.
@property (assign, nonatomic) CGFloat currentAngle;
@property (assign, nonatomic) CGPoint startPoint;
@property (assign, nonatomic) CGFloat previousAngle;
Нам также пригодится значение длины окружности.
@property (assign, nonatomic, readonly) CGFloat circleLength;
Делаем его, когда выставляем радиус окружности. Переопределим
setter
.- (void)setCircleRadius:(CGFloat)circleRadius {
_circleRadius = circleRadius;
_circleLength = 2 * M_PI * circleRadius;
}
Проинициализируем
startPoint
в методе - (void)commonInit
как середину contentOffset
, чтобы можно было вращать в обе стороны. self.startPoint = self.scrollView.contentOffset;
Приготовления сделаны. Теперь пора переходить к математике. Для начала нам понадобится измерение расстояния между двумя точками. Используем простую формулу — корень из суммы квадратов разностей соответствующих координат.
В коде это выглядит так:
- (CGFloat)deltaWithOffset:(CGPoint)offset {
return sqrt(pow(self.startPoint.x - offset.x, 2) + pow(self.startPoint.y - offset.y, 2));
}
Теперь определим направление движения, то есть знак у длины. Для этого определим, в каком месте относительно центра круга находится точка касания. Почему это важно? Обратите внимание, как одинаковый жест приводит к разному направлению движения. Необходимо учесть этот момент.
Определяем новый тип, в котором описываем положение точки слева и справа от центра круга,
typedef NS_ENUM(NSUInteger, AYNCircleViewHalf) {
AYNCircleViewHalfLeft,
AYNCircleViewHalfRight,
};
и метод, который по точке вычисляет положение:
- (AYNCircleViewHalf)halfWithPoint:(CGPoint)point {
return point.x > self.contentView.center.x ? AYNCircleViewHalfRight : AYNCircleViewHalfLeft;
}
Готово. Теперь мы знаем в какой половине круга находится точка.
На основе этой информации вычисляем «знак» поворота.
- (CGFloat)signWithOffset:(CGPoint)offset half:(AYNCircleViewHalf)half {
CGFloat sign = offset.x > self.startPoint.x ? -1 : 1;
BOOL isYDominant = fabs(offset.y - self.startPoint.y) > fabs(offset.x - self.startPoint.x);
if (isYDominant) {
sign = offset.y > self.startPoint.y ? -1 : 1;
sign *= half == AYNCircleViewHalfLeft ? -1 : 1;
}
return sign;
}
На основе знака и длины теперь вычисляем угол поворота. Пусть delta — это количество длин окружности в нашем смещении. Тогда угол — произведение 2 радиан на
delta
.- (CGFloat)angleWithOffset:(CGPoint)offset half:(AYNCircleViewHalf)half {
CGFloat delta = [self deltaWithOffset:offset] / self.circleLength;
CGFloat sign = [self signWithOffset:offset half:half];
return sign * delta * 2 * M_PI;
}
Теперь определяем метод, который будет отнимать лишние периоды у углов. Например, угол в 3 до . Нам необходимо, чтобы он работал и с отрицательными углами.
- (CGFloat)floorAngle:(CGFloat)angle {
NSInteger times = floorf(fabs(angle) / (2 * M_PI));
NSInteger sign = angle > 0 ? -1 : 1;
return angle + sign * times * 2 * M_PI;
}
Готово, у нас есть математическая база для поворота нашего круга.
Теперь реализуем поворот всей нашей иерархии. Для этого используем метод над UIView +
- (void)animateWithDuration:animations:
.
- (void)rotateWithAngle:(CGFloat)angle {
[UIView animateWithDuration:0.1 animations:^{
self.contentView.transform = CGAffineTransformMakeRotation(angle);
}];
}
В данном случае мы не ослабляем ссылку, потому что когда анимация закончится, ссылка на
self
пропадет.Определим метод делегата - (void)scrollViewDidScroll:
, в котором будем производить все вычисления и изменения состояния:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGPoint point = [scrollView.panGestureRecognizer locationInView:self];
CGFloat tickOffset = [self angleWithOffset:scrollView.contentOffset half:[self halfWithPoint:point]];
self.currentAngle = [self floorAngle:(self.previousAngle + tickOffset)];
[self rotateWithAngle:self.currentAngle];
self.previousAngle = self.currentAngle;
self.startPoint = scrollView.contentOffset;
}
Готово. Смотрим, как это выглядит.
Теперь реализуем такое поведение контрола, чтобы после сильной прокрутки он в дальнейшем останавливался ровно на каком-либо числе. Для этого нам нужно вычислить и изменить targetContentOffset
в методе делегата - (void)scrollViewWillEndDragging:withVelocity:targetContentOffset:
. Дополним наш математический аппарат.
Первым делом, нормализуем угол до кратного к angleStep
. Определим для этого метод.
- (CGFloat)normalizeAngle:(CGFloat)angle {
return lroundf(angle / self.angleStep) * self.angleStep;
}
Остановимся на описании способа вычисления «нормализованной» точки остановки
scrollView
. Метод - (void)scrollViewWillEndDragging:withVelocity:targetContentOffset:
возвращает contentOffset
, который будет при остановке scrollView
. Наша цель — изменить этот CGPoint
. Отталкиваемся от нормализованного значения длины смещения.Пусть target
— это targetContentOffset
. Рассчитываем такую точку normalizedContentOffset
, чтобы полученная длина переводилась в угол кратный angleStep
. Тогда контрол остановит вращение точно на числе.
Для этого рассчитаем «нормализованную» длину и, зная угол наклона исходного смещения, находим координаты конца этого смещения. Изменив координаты, изменим и точку остановки.
Итак, алгоритм нахождения:
— находим длину по текущим значениям startPoint
, targetPoint
,
— находим угол поворота по длине,
— нормализуем угол,
— находим нормализованную длину,
— находим конечную точку на новой длине.
На последнем пункте остановимся дополнительно. Чтобы получить координаты конечной точки, необходимо найти проекции нормализованного смещения на оси Ох и Оу. Это можно сделать, зная угол наклона смещения. Вычисляем тангенс угла наклона как отношение
Используя эти данные, с легкостью находим координаты конца «нормализованного» отрезка.
- (CGPoint)endPointWithTargetPoint:(CGPoint)targetPoint scrollView:(UIScrollView *)scrollView {
CGPoint point = [scrollView.panGestureRecognizer locationInView:self];
CGFloat tickOffset = [self angleWithOffset:targetPoint half:[self halfWithPoint:point]];
CGFloat rotationAngle = self.previousAngle + tickOffset;
CGFloat delta = [self deltaWithAngle:rotationAngle];
CGFloat normalizedRotationAngle = [self normalizeAngle:rotationAngle];
CGFloat normalizedDelta = [self deltaWithAngle:normalizedRotationAngle];
CGFloat inclination = [self inclinationWithOffset:targetPoint startPoint:self.startPoint];
CGFloat sign = normalizedRotationAngle <= 0 ? -1 : 1;
CGPoint result = CGPointMake(targetPoint.x + sign * (normalizedDelta - delta) * cos(inclination), targetPoint.y + sign * (normalizedDelta - delta) * sin(inclination));
return result;
}
Метод для вычисления угла наклона нашего смещения:
- (CGFloat)inclinationWithOffset:(CGPoint)offset startPoint:(CGPoint)startPoint {
CGFloat y = (offset.y - self.startPoint.y);
CGFloat x = (offset.x - self.startPoint.x);
if (!isnan(x) && x != 0) {
return atan2(y, x);
}
return 0;
}
Используем функцию atan2, т.к. она правильно приводит углы в зависимости от четверти.
Готово. Теперь определяем метод делегата, в котором производим расчеты:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
*targetContentOffset = [self endPointWithTargetPoint:*targetContentOffset scrollView:scrollView];
}
Бывают ситуации, когда пользователь прокрутил так мало, что не достиг следующего или предыдущего числа. Тогда контрол должен отпрыгнуть на ближайший. Для этого реализуем следующий метод делегата:
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
if (!decelerate) {
self.currentAngle = [self normalizeAngle:self.previousAngle];
[self rotateWithAngle:self.currentAngle];
}
}
Результат:
Методы делегата
Создадим методы делегата нашей крутилки, чтоб пользователи класса могли назначать какой-либо контроллер его делегатом и получали от него информацию. Но для начала создаем свойство, которое будет возвращать значение на циферблате:
@property (nonatomic, readonly) NSInteger value;
Определим геттер для него:
- (NSInteger)value {
NSInteger value = self.currentAngle > 0 ? floorf(self.currentAngle / self.angleStep) - self.numberOfLabels : floorf(self.currentAngle / self.angleStep);
return labs(value) % self.numberOfLabels;
}
Готово. Теперь можно узнать текущее значение на циферблате.
Займемся делегатом.
@protocol AYNCircleViewDelegate
@optional
- (void)circleViewWillRotate:(AYNCircleView *)circleView;
- (void)circleView:(AYNCircleView *)circleView didRotateWithValue:(NSUInteger)value;
@end
Для начала этих двух методов хватит. Нас больше интересует второй метод, в котором мы получим значение крутилки в текущий момент.
Создаем свойство делегата у AYNCircleView
:
@property (weak, nonatomic) id delegate;
Определим места, в которых эти методы будут вызываться.
Так как эти методы связаны с вращением, то нас интересует метод - (void)rotateWithAngle:
.
Как он выглядит с вызовами методов:
- (void)rotateWithAngle:(CGFloat)angle {
if (self.delegate && [self.delegate respondsToSelector:@selector(circleViewWillRotate:)]) {
[self.delegate circleViewWillRotate:self];
}
[UIView animateWithDuration:0.1 animations:^{
self.contentView.transform = CGAffineTransformMakeRotation(angle);
} completion:^(BOOL finished) {
if (self.delegate && [self.delegate respondsToSelector:@selector(circleView:didRotateWithValue:)]) {
[self.delegate circleView:self didRotateWithValue:self.value];
}
}];
}
Готово. Реализуем методы делегата в нашем
AYNViewController
.Не забываем сделать себя делегатом этой крутилки:
self.circleView.delegate = self;
Теперь реализуем один из методов. Сначала в
Interface Builder
выставим label
, в котором отображается значение.@property (weak, nonatomic) IBOutlet UILabel *valueLabel;
Переходим к реализации.
#pragma mark - Circle View Delegate
- (void)circleView:(AYNCircleView *)circleView didRotateWithValue:(NSUInteger)value {
self.valueLabel.text = [NSString stringWithFormat:@"%ld", value];
}
Результат готов.
Вот таким нехитрым способом я изобрел свой кастомный контрол. Весь проект доступен на гите.