В первой части статьи я подробно воссоздал процесс реализации циферблата. Теперь мы подошли к самому интересному и сложному этапу создания собственного кастомного контрола.
Здесь я описываю математику вращения, которую я использовал по совету из доклада, озвученного на MBLTdev.
Основной координатной составляющей у нас служит
Введем несколько свойств, которые будут хранить информацию о текущем положении крутилки.
Нам также пригодится значение длины окружности.
Делаем его, когда выставляем радиус окружности. Переопределим
Проинициализируем
Приготовления сделаны. Теперь пора переходить к математике. Для начала нам понадобится измерение расстояния между двумя точками. Используем простую формулу — корень из суммы квадратов разностей соответствующих координат.
В коде это выглядит так:
Теперь определим направление движения, то есть знак у длины. Для этого определим, в каком месте относительно центра круга находится точка касания. Почему это важно? Обратите внимание, как одинаковый жест приводит к разному направлению движения. Необходимо учесть этот момент.
Определяем новый тип, в котором описываем положение точки слева и справа от центра круга,
и метод, который по точке вычисляет положение:
Готово. Теперь мы знаем в какой половине круга находится точка.
На основе этой информации вычисляем «знак» поворота.
На основе знака и длины теперь вычисляем угол поворота. Пусть delta — это количество длин окружности в нашем смещении. Тогда угол — произведение 2 радиан на
Теперь определяем метод, который будет отнимать лишние периоды у углов. Например, угол в 3 до . Нам необходимо, чтобы он работал и с отрицательными углами.
Готово, у нас есть математическая база для поворота нашего круга.
Теперь реализуем поворот всей нашей иерархии. Для этого используем метод над
В данном случае мы не ослабляем ссылку, потому что когда анимация закончится, ссылка на
Определим метод делегата
Готово. Смотрим, как это выглядит.
Теперь реализуем такое поведение контрола, чтобы после сильной прокрутки он в дальнейшем останавливался ровно на каком-либо числе. Для этого нам нужно вычислить и изменить
Первым делом, нормализуем угол до кратного к
Остановимся на описании способа вычисления «нормализованной» точки остановки
Пусть
Для этого рассчитаем «нормализованную» длину и, зная угол наклона исходного смещения, находим координаты конца этого смещения. Изменив координаты, изменим и точку остановки.
Итак, алгоритм нахождения:
— находим длину по текущим значениям
— находим угол поворота по длине,
— нормализуем угол,
— находим нормализованную длину,
— находим конечную точку на новой длине.
На последнем пункте остановимся дополнительно. Чтобы получить координаты конечной точки, необходимо найти проекции нормализованного смещения на оси Ох и Оу. Это можно сделать, зная угол наклона смещения. Вычисляем тангенс угла наклона как отношение
Используя эти данные, с легкостью находим координаты конца “нормализованного” отрезка.
Метод для вычисления угла наклона нашего смещения:
Используем функцию atan2, т.к. она правильно приводит углы в зависимости от четверти.
Готово. Теперь определяем метод делегата, в котором производим расчеты:
Бывают ситуации, когда пользователь прокрутил так мало, что не достиг следующего или предыдущего числа. Тогда контрол должен отпрыгнуть на ближайший. Для этого реализуем следующий метод делегата:
Результат:
Создадим методы делегата нашей крутилки, чтоб пользователи класса могли назначать какой-либо контроллер его делегатом и получали от него информацию. Но для начала создаем свойство, которое будет возвращать значение на циферблате:
Определим геттер для него:
Готово. Теперь можно узнать текущее значение на циферблате.
Займемся делегатом.
Для начала этих двух методов хватит. Нас больше интересует второй метод, в котором мы получим значение крутилки в текущий момент.
Создаем свойство делегата у
Определим места, в которых эти методы будут вызываться.
Так как эти методы связаны с вращением, то нас интересует метод
Как он выглядит с вызовами методов:
Готово. Реализуем методы делегата в нашем
Не забываем сделать себя делегатом этой крутилки:
Теперь реализуем один из методов. Сначала в
Переходим к реализации.
Результат готов.
Вот таким нехитрым способом я изобрел свой кастомный контрол. Весь проект доступен на гите.
Вращение циферблата
Здесь я описываю математику вращения, которую я использовал по совету из доклада, озвученного на 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];
}
Результат готов.
Вот таким нехитрым способом я изобрел свой кастомный контрол. Весь проект доступен на гите.
Поделиться с друзьями
storoj
всю эту математику засунуть бы в UICollectionViewLayout
haron1020
Для меня это было первым этапом изучения создания кастомного контрола, тем более я хотел дополнить доклад, который был на MBLTdev. Следующим этапом изучения я себе поставил именно UICollectionViewLayout, его возможности. Спасибо за совет!