Реализация кастомного 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; 

Приготовления сделаны. Теперь пора переходить к математике. Для начала нам понадобится измерение расстояния между двумя точками. Используем простую формулу — корень из суммы квадратов разностей соответствующих координат.

$d = \sqrt {{{({x_2} - {x_1})}^2} + {{({y_2} - {y_1})}^2}}$

В коде это выглядит так:

- (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$\pi$ радиан на 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$\pi$ до $\pi$. Нам необходимо, чтобы он работал и с отрицательными углами.

- (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,
— находим угол поворота по длине,
— нормализуем угол,
— находим нормализованную длину,
— находим конечную точку на новой длине.

На последнем пункте остановимся дополнительно. Чтобы получить координаты конечной точки, необходимо найти проекции нормализованного смещения на оси Ох и Оу. Это можно сделать, зная угол наклона смещения. Вычисляем тангенс угла наклона как отношение

$tg\alpha = \frac{y}{x}$

Используя эти данные, с легкостью находим координаты конца “нормализованного” отрезка.

- (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 <NSObject>  @optional  - (void)circleViewWillRotate:(AYNCircleView *)circleView; - (void)circleView:(AYNCircleView *)circleView didRotateWithValue:(NSUInteger)value;  @end

Для начала этих двух методов хватит. Нас больше интересует второй метод, в котором мы получим значение крутилки в текущий момент.

Создаем свойство делегата у AYNCircleView:

@property (weak, nonatomic) id<AYNCircleViewDelegate> 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]; }

Результат готов.

Вот таким нехитрым способом я изобрел свой кастомный контрол. Весь проект доступен на гите.
ссылка на оригинал статьи https://habrahabr.ru/post/326388/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *