原文链接: http://draveness.me/ouroboros-de-shi-xian-interaction-library-in-objectivec/
Ouroboros 是一个根据 scrollView
滚动的距离完成动画的一个仓库. 灵感来源于 javascript
的第三方框架 scrollMagic.
我在使用 scrollMagic
的过程中, 觉得这种根据当前滚动距离改变视图状态的方式非常的优雅, 而且这种动画是可以回退的, 在我看来, 这种动画的方式完全适合于在 iOS App 中完成引导界面的动画.
在以往的开发经验中, 经常需要根据 scrollView
的滚动距离完成一些相应的动画, 而 Ouroboros
就是 iOS 中用于解决这一类问题的框架.
Ouroboros
与其说是为视图添加动画, 不如说是改变视图当前的状态. 当动画根据 scrollView
的滚动距离而进行时, 所谓的 duration
就失去了意义. 我们不会知道动画什么时候开始, 什么时候结束, "动画" 的状态完全取决于位置. 而这也是这些动画可以后退的最重要原因.
为视图添加的其实并不是通常意义的动画, 而是根据当前的
scrollView
的滚动位置, 计算出当前视图的状态, 然后再更新视图的这样一组动作.
Ouroboros
的组成还是非常的简单, 总共分为以下几部分
UIScrollView
的分类, 提供当前位置Ouroboros
, 聚合一组动画属性相同的 Scale
Scale
, 用于计算当前状态UIView
的分类, 为添加动画提供便利的方法, 并负责更新视图的状态UIScrollView+Ouroboros
这个分类的主要作用就是为整个 Ouroboros
提供数据支持, 也就是导致视图状态改变的原数据, contentOffset
.
根据 UIScrollView
的滚动方向不同, 而 UIScrollView
的属性 ou_scrollDirection
决定了是根据 contentOffset.x
还是 contentOffset.y
来改变视图的状态.
我在 UIScrollView
中通过 method swizillzing 动态地改变了原有的 -setContentOffset:
方法的实现.
- (void)ou_setContentOffset:(CGPoint)contentOffset {
[self ou_setContentOffset:contentOffset];
[[NSNotificationCenter defaultCenter] postNotificationName:OURScrollViewUpdateContentOffset
object:nil
userInfo:@{@"contentOffset": [NSValue valueWithCGPoint:contentOffset],
@"direction":@(self.ou_scrollDirection)}];
}
当 UIScrollView
滚动时, 它的 contentOffset
会不断发生改变, 而我们就可以通过方法调剂改变原有方法的实现, 在 contentOffset
改变时发出通知, 来通知 Ouroboros
中的其他模块根据 contentOffset
和 ou_scrollDirection
来对视图的状态进行更新.
UIView+Ouroboros
这个分类与大多数框架中的分类的作用一样, 为 UIView
提供"开箱即用"的方法, 其核心方法为 -our_animateWithProperty:configureBlock:
, :
- (void)our_animateWithProperty:(OURAnimationProperty)property
configureBlock:(ScaleAnimationBlock)configureBlock {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateState:) name:OURScrollViewUpdateContentOffset object:nil];
Ouroboros *ouroboros = [self ouroborosWithProperty:property];
Scale *scale = [[Scale alloc] init];
configureBlock(scale);
NSMutableArray<Scale *> *scales = [ouroboros mutableArrayValueForKey:@"scales"];
[scales addObject:scale];
}
当这个方法被调用时:
OURScrollViewUpdateContentOffset
通知, 当 contentOffset
改变时调用 -updateState:
方法.property
, 获取一个对应的 Ouroboros
的对象, 这个对象的作用就是为了管理一组 Scale
.ouroboros
之后, 会实例化一个 scale
block
配置这个 scale
ouroboros
持有的 scales
数组中在这里有这样几个问题, 每一个视图对应的一组 property
相同的动画, 都由相同的 ouroboros
对象来处理, 这样做的主要原因是防止在同一区间中改变视图的同一属性多次的问题.
[yellowView our_animateWithProperty:OURAnimationPropertyViewHeight configureBlock:^(Scale * _Nonnull scale) {
scale.toValue = @(200);
scale.trigger = 0;
scale.offset = 320 * 2;
}];
[yellowView our_animateWithProperty:OURAnimationPropertyViewHeight configureBlock:^(Scale * _Nonnull scale) {
scale.toValue = @(400);
scale.trigger = 320;
scale.offset = 320 * 0.5;
}];
在这里当我们第一次调用 -our_animateWithProperty:configureBlock:
方法时, 在 [0, 640]
之间改变了视图的高度, 而在我们第二次为视图高度添加动画时, 在 [320, 480]
之间改变了视图的高度, 而这时就造成了冲突. 而这就是 ouroboros
要解决的一个问题.
当 UIScrollView
滚动并且发出通知告知视图需要更新状态时, 就会调用在该分类中另一个比较重要的方法 -updateState:
notification
对象中取出 contentOffset
和 ou_scrollDirection
的值ouroboroses
数组ouroboros
获取当前位置下的值, 根据 ouroboros.property
更新视图的状态.- (void)updateState:(NSNotification *)notification {
CGPoint contentOffset = [[notification userInfo][@"contentOffset"] CGPointValue];
OURScrollDirection direction = [[notification userInfo][@"direction"] integerValue];
for (Ouroboros *ouroboros in self.ouroboroses) {
CGFloat currentPosition = 0;
if (direction == OURScrollDirectionHorizontal) {
currentPosition = contentOffset.x;
} else {
currentPosition = contentOffset.y;
}
id value = [ouroboros getCurrentValueWithPosition:currentPosition];
OURAnimationProperty property = ouroboros.property;
switch (property) {
case OURAnimationPropertyViewBackgroundColor: {
self.backgroundColor = value;
}
break;
...
}
}
}
其中的 -getCurrentValueWithPosition:
方法会在下面介绍.
Ouroboros
作为这个框架的核心类, 它为视图的更新提供数据支持, 并且负责管理器持有的 Scale
对象, 发现其中的可能的动画冲突,
在这个类中也提供了几个创建 CGRect
CGSize
和 CGPoint
的方法.
NSValue *NSValueFromCGRectParameters(CGFloat x, CGFloat y, CGFloat width, CGFloat height);
NSValue *NSValueFromCGPointParameters(CGFloat x, CGFloat y);
NSValue *NSValueFromCGSizeParameters(CGFloat width, CGFloat height);
当 UIView
调用 -updateState:
方法更新视图时, 调用了 -getCurrentValueWithPosition:
方法, 该方法根据当前的状态获取了视图该 property
对应的值.
- (id)getCurrentValueWithPosition:(CGFloat)position {
Scale *previousScale = nil;
Scale *afterScale = nil;
for (Scale *scale in self.scales) {
if ([scale isCurrentPositionOnScale:position]) {
CGFloat percent = (position - scale.trigger) / scale.offset;
return [scale calculateInternalValueWithPercent:percent];
} else if (scale.trigger > position && (!afterScale || afterScale.trigger > scale.trigger)) {
afterScale = scale;
} else if (scale.stop < position && (!previousScale || previousScale.stop < scale.stop)) {
previousScale = scale;
}
}
if (previousScale) {
return previousScale.toValue;
} else if (afterScale) {
return afterScale.fromValue;
}
NSAssert(NO, @"FATAL ERROR, Unknown current value for property %@", @(self.property));
return [[NSObject alloc] init];
}
Ouroboros
对象会遍历持有的 scales
数组, 这时可能会发生三种情况:
scale
的区间中, 就会直接通过 -calculateInternalValueWithPercent:
方法, 返回当前位置的数值.scale
上, 但是存在 previousScale
就会返回 previousScale.toValue
, 也就是 previousScale
结束时的值.previousScale
, 但是存在 afterScale
那么就会返回 afterScale.fromValue
, 也就是 afterScale
的初始值.而当该方法调用时, 一定会存在至少一个
scale
, 所以- getCurrentValueWithPosition:
方法总会返回正确的值.
比如说, 存在以下两个 scales
[100, 200] [400, 500]
[100, 200]
或者 [400, 500]
之间, 那么直接通过这两个 scale
计算就能获得当前位置的值.[-Inf, 100]
之间, 就会返回 100
处的值.[200, 400]
之间, 就会返回 200
处的值[500, +Inf]
之间, 就会返回 500
处的值Ouroboros
这个类的另一个作用就是发现 scale
之间的覆盖, 而这个是通过 Objective-C
语言中的 KVO
完成的, 实现这个功能主要调用了三个方法:
-mutableArrayValueForKey:
-insertObject:in<Key>AtIndex:
-removeObjectFrom<Key>AtIndex:
Objective-C
中对数组的 KVO
主要由这三个方法实现, 首先需要通过 -mutableArrayValueForKey:
方法获取需要 observe 的可变数组
NSMutableArray<Scale *> *scales = [ouroboros mutableArrayValueForKey:@"scales"];
然后在操作这个数组, 例如 -addObject:
等方法时, 就会调用 -insertObject:in<Key>AtIndex:
, -removeObjectFrom<Key>AtIndex:
方法, 而我们在只要在 ouroboros
覆写这两个方法就可以了.
- (void)insertObject:(Scale *)currentScale inScalesAtIndex:(NSUInteger)index {
Scale *previousScale = nil;
Scale *afterScale = nil;
for (Scale *scale in self.scales) {
if ([scale isSeparateWithScale:currentScale]) {
if (scale.trigger >= currentScale.stop && (!afterScale || afterScale.trigger >= scale.stop)) {
afterScale = scale;
} else if (scale.stop <= currentScale.trigger && (!previousScale || previousScale.stop <= scale.trigger)) {
previousScale = scale;
}
} else {
NSAssert(NO, @"Can not added an overlapping scales to the same ouroboros.");
}
}
if (previousScale) {
currentScale.fromValue = previousScale.toValue;
} else {
currentScale.fromValue = self.startValue;
}
if (afterScale) {
afterScale.fromValue = currentScale.toValue;
}
[self.scales insertObject:currentScale atIndex:index];
}
- (void)removeObjectFromScalesAtIndex:(NSUInteger)index {
[self.scales removeObjectAtIndex:index];
}
在这里 -removeObjectFromScalesAtIndex:
方法的实现并不重要, 我们需要关注的是 -insertObject:inScalesAtIndex:
方法. 这个方法在最开始会先将即将插入的 scale
与其它所有的 scale
进行比较, 查看是否有 overlapping, 并找出最近的 previousScale
和 afterScale
. 重新设置 currentScale
和 afterScale
的初始值. 我相信由于上面也有类似的代码, 这里的逻辑也很好解释.
Scale
作为 Ouroboros
的一部分, 它的核心作用就是保存一个 Ouroboros
动画的状态
然后根据这些状态和 percent
计算视图的状态, 也就是 -calculateInternalValueWithPercent:
方法.
这个方法的实现非常长, 我们在这里只截取其中的一部分
- (id)calculateInternalValueWithPercent:(CGFloat)percent {
percent = [self justifyPercent:percent];
CGFloat value = self.functionBlock(self.offset * percent * 1000, 0, 1, self.offset * 1000);
id result = [[NSValue alloc] init];
if ([self.fromValue isKindOfClass:[NSNumber class]]) {
CGFloat fromValue = [self.fromValue floatValue];
CGFloat toValue = [self.toValue floatValue];
CGFloat resultValue = fromValue + (toValue - fromValue) * value;
result = @(resultValue);
}
...
return result;
}
当 percent
传进来之后, 要调用 -justifyPercent:
方法保证当前 percent
值的范围在 [0, 1]
之间, 然后通过 functionBlock
根据不同的函数曲线偏移当前的 offset
值, 默认的函数曲线为线性的, 也就是不会改变 percent
值. 在这之后, 由于 fromValue
和 toValue
的值有不同的类型, 要根据值类型的不同, 计算出不同的 resultValue
. 公式差不多都是这样的:
fromValue + (toValue - fromValue) * value
如果是 UIColor
就会分别计算 red
green
blue
alpha
四部分, 然后重新组成 UIColor
, 如果是 CGRect
等其他值也会分别计算各个组成部分, 最后再重新组合成对应的值的类型.
到这里, 整个 Ouroboros
框架的实现就已经基本介绍完了, 整个框架的实现还是比较简单的. 如果你有更好的想法或者有新的建议, 可以在 github 上开一个 issue 或者 PR.
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.