В первой части статьи я подробно воссоздал процесс реализации циферблата. Теперь мы подошли к самому интересному и сложному этапу создания собственного кастомного контрола.
Вращение циферблата
Здесь я описываю математику вращения, которую я использовал по совету из доклада, озвученного на 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 <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/
Добавить комментарий