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

Теперь определим направление движения, то есть знак у длины. Для этого определим, в каком месте относительно центра круга находится точка касания. Почему это важно? Обратите внимание, как одинаковый жест приводит к разному направлению движения. Необходимо учесть этот момент.

63cfc94dfc20474c9e3664fd44db9c8b.png11f32adf589c434baa144d42d6fc36e6.png

Определяем новый тип, в котором описываем положение точки слева и справа от центра круга,

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;
}

Готово. Смотрим, как это выглядит.

ad88ba8c5b744b30a415dae1582918b4.gif

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

0f7f7eed71a24112a4b4726026cc455c.png

Пусть 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];
    }
}

Результат:

afee839a03164dd3811a948bc399fb58.gif

Методы делегата


Создадим методы делегата нашей крутилки, чтоб пользователи класса могли назначать какой-либо контроллер его делегатом и получали от него информацию. Но для начала создаем свойство, которое будет возвращать значение на циферблате:
@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];
}

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

6046322695ea4077b4d1725eaf2f2e81.gif

Вот таким нехитрым способом я изобрел свой кастомный контрол. Весь проект доступен на гите.

Комментарии (0)

© Habrahabr.ru