iOS特效之仿Mac窗口最小化的神奇效果

点击获取本文示例代码

我希望我可以成为占位图

前言

这次仿照Mac窗口最小化时的神奇效果(官方的中文版本是这么叫的,听起来很尴尬),做了一个iOS版本的。基础代码都沿用自iOS特效之破碎的ViewController。先来看一下效果图。

原理

首先要分析一下官方的动画是如何进行的,下面是效果的截图。动画分为两步,先是将图片扭曲成下面的样子,然后再吸入到左侧。想要做图片扭曲,用一个nxm的3D网格就可以了。n和m越大,扭曲后得到的边缘越平滑。



在上图的基础上加入一个坐标轴,这样便于观察规律。

在动画执行过程中,网格上的点会沿着一个方向缩放,我们称缩放的轴为缩放轴,图中的缩放轴是y轴。同时还需要在缩放轴上指定一个缩放中心点。在动画的第二个阶段,所有点会沿着一个方向移动,我们称这个轴为移动轴,图中的移动轴是x轴。

动画第一阶段

在动画的第一个阶段中,网格上的点只在缩放轴上移动。我们假设一个点在移动轴上的位置为movLoc,那么我们可以使用公式0.5 * 0.98 * cos(3.14 * movLoc + 3.14) + 0.5 + 0.01;计算出第一阶段结束时,该点需要向缩放中心点缩放的量。为什么是这个公式呢,我给大家贴一张图就清楚了。是不是和上面的边缘曲线有点像。图我是用Mac自带的Grapher绘制的。在调试曲线的过程中Grapher的确非常好用。公式里的0.98和0.01是相关的两个量,控制左边窄口的大小。0.01 = (1 - 0.98) / 2。动画第一阶段主要的工作就是根据当前动画的进度百分比,控制点到达最终缩放量的进度即可。

动画第二阶段

第二阶段主要就是移动轴上的移动,我们可以根据最远移动距离和当前的动画进度计算出当前点在移动轴上的位置。然后根据当前的位置计算出缩放轴上需要的缩放量。最远距离可以通过吸入点和另一侧的边界计算出来。

Shader

了解完原理我们来看Shader代码吧。Swift代码比较简单,只是生成了一个撑满屏幕的nxm网格,稍候再说。

传入Shader的数据

VertexInVertexOut很普通,包含顶点位置和纹理坐标。Uniforms里包含了动画相关的信息,当前动画经过的时间animationElapsedTime,动画总时间animationTotalTime,吸入点gatherPoint

struct VertexIn
{
    packed_float3  position;
    packed_float2  texcoord;
};

struct VertexOut
{
    float4  position [[position]];
    float2  texcoord;
};

struct Uniforms
{
    float animationElapsedTime;
    float animationTotalTime;
    packed_float3 gatherPoint;
};

动画实现

动画的实现都在Vertex Shader里。步骤如下。

  • 计算并规范动画进度,得到动画进度animationPercent
VertexOut outVertex;
VertexIn inVertex = vertexIn[vid];
float animationPercent = uniforms.animationElapsedTime / uniforms.animationTotalTime;
animationPercent = animationPercent > 1.0 ? 1.0 : animationPercent;
  • 求解移动轴scaleAxis和缩放轴moveAxis,以及最远移动距离。我们可以通过移动轴scaleAxis和缩放轴moveAxis获取点或者向量对应轴的分量。
// 求解缩放轴和移动轴
float moveMaxDisplacement = 2.0; // 最远移动位移,带符号
int scaleAxis = 0; // 默认缩放轴为X
int moveAxis = 1;   // 默认移动轴为Y,即沿着y方向吸入的效果
if (uniforms.gatherPoint[0] <= -1 || uniforms.gatherPoint[0] >= 1) {
    scaleAxis = 1;
    moveAxis = 0;
}
if (uniforms.gatherPoint[moveAxis] >= 0) {
    moveMaxDisplacement = uniforms.gatherPoint[moveAxis] + 1;
} else {
    moveMaxDisplacement = uniforms.gatherPoint[moveAxis] - 1;
  • 定义第一阶段动画在总动画中的占比。
// 动画第一阶段的时间占比
float animationFirstStagePercent = 0.4;
  • 计算移动轴的动画当前执行到的进度moveAxisAnimationPercent,在第一阶段执行完之前,这个值一直是0。
// 移动轴的动画只有在第一阶段结束后才开始进行。
float moveAxisAnimationPercent = (animationPercent - animationFirstStagePercent) / (1.0 - animationFirstStagePercent);
moveAxisAnimationPercent = moveAxisAnimationPercent < 0.0 ? 0.0 : moveAxisAnimationPercent;
moveAxisAnimationPercent = moveAxisAnimationPercent > 1.0 ? 1.0 : moveAxisAnimationPercent;
  • 根据点在移动轴上规范化后的坐标计算缩放量的最终值。在第一阶段时,根据最终缩放量和当前动画进度计算当前的缩放量scaleAxisCurrentValue。第二阶段时,直接使用最终缩放量,因为此时缩放量只和移动轴上坐标有关。
// 用于缩放轴计算缩放量的因子
float scaleAxisFactor = abs(uniforms.gatherPoint[moveAxis] - (inVertex.position[moveAxis] + moveMaxDisplacement *
moveAxisAnimationPercent)) / abs(moveMaxDisplacement);
float scaleAxisAnimationEndValue = 0.5 * 0.98 * cos(3.14 * scaleAxisFactor + 3.14) + 0.5 + 0.01;
float scaleAxisCurrentValue = 0;
if (animationPercent <= animationFirstStagePercent) {
    scaleAxisCurrentValue = 1 +  (scaleAxisAnimationEndValue - 1) * animationPercent / animationFirstStagePercent;
} else {
    scaleAxisCurrentValue = scaleAxisAnimationEndValue;
}
  • 根据移动轴上动画的进度moveAxisAnimationPercent和缩放轴的缩放量scaleAxisCurrentValue计算最终顶点的位置。
float newMoveAxisValue = inVertex.position[moveAxis] + moveMaxDisplacement * moveAxisAnimationPercent;
float newScaleAxisValue = inVertex.position[scaleAxis] - (inVertex.position[scaleAxis] - uniforms.gatherPoint[scaleAxis]) * (1 - scaleAxisCurrentValue);

float3 newPosition = float3(0, 0, inVertex.position[2]);
newPosition[moveAxis] = newMoveAxisValue;
newPosition[scaleAxis] = newScaleAxisValue;
outVertex.position = float4(newPosition, 1.0);
outVertex.texcoord = inVertex.texcoord;
return outVertex;

Vertex Shader到此就结束了,Fragment Shader很简单,采样,返回颜色。

constexpr sampler s(coord::normalized, address::repeat, filter::linear);

fragment float4 passThroughFragment(VertexOut inFrag [[stage_in]],
                                     texture2d<float> diffuse [[ texture(0) ]],
                                    const device Uniforms& uniforms [[ buffer(0) ]])
{
    float4 finalColor = diffuse.sample(s, inFrag.texcoord);
    return finalColor;
};

Swift代码

Swift代码里基本重用破碎效果的代码,在MagicalEffectView.swift中,最核心的代码也就是构建网格这一段了。

private func buildMesh() -> [Float] {
    let viewWidth: Float = Float(UIScreen.main.bounds.width)
    let viewHeight: Float = Float(UIScreen.main.bounds.height)
    let meshCols: Int = 10;//Int(viewWidth / Float(meshUnitSizeInPixel.width));
    let meshRows: Int = meshCols * Int(viewHeight / viewWidth);//Int(viewHeight / Float(meshUnitSizeInPixel.height));
    let meshUnitSizeInPixel: CGSize = CGSize.init(width: CGFloat(viewWidth / Float(meshCols)), height: CGFloat(viewHeight /
Float(meshRows))) // 每个mesh单元的大小
    let sizeXInMetalTexcoord = Float(meshUnitSizeInPixel.width) / viewWidth * 2;
    let sizeYInMetalTexcoord = Float(meshUnitSizeInPixel.height) / viewHeight * 2;
    var vertexDataArray: [Float] = []
    for row in 0..<meshRows {
        for col in 0..<meshCols {
            let startX = Float(col) * sizeXInMetalTexcoord - 1.0;
            let startY = Float(row) * sizeYInMetalTexcoord - 1.0;
            let point1: [Float] = [startX, startY, 0.0, Float(col) / Float(meshCols), Float(row) / Float(meshRows)];
            let point2: [Float] = [startX + sizeXInMetalTexcoord, startY, 0.0, Float(col + 1) / Float(meshCols), Float(row) /
Float(meshRows)];
            let point3: [Float] = [startX + sizeXInMetalTexcoord, startY + sizeYInMetalTexcoord, 0.0, Float(col + 1) /
Float(meshCols), Float(row + 1) / Float(meshRows)];
            let point4: [Float] = [startX, startY + sizeYInMetalTexcoord, 0.0, Float(col) / Float(meshCols), Float(row + 1) /
Float(meshRows)];
            
            vertexDataArray.append(contentsOf: point3)
            vertexDataArray.append(contentsOf: point2)
            vertexDataArray.append(contentsOf: point1)
            
            vertexDataArray.append(contentsOf: point3)
            vertexDataArray.append(contentsOf: point1)
            vertexDataArray.append(contentsOf: point4)
        }
    }
    return vertexDataArray
}

根据网格单元格的大小,构建顶点位置和UV数组。还有就是对Uniforms进行了修改。包含动画相关的信息。

struct Uniforms {
    var animationElapsedTime: Float = 0.0
    var animationTotalTime: Float = 0.6
    var gatherPointX: Float = 0.8
    var gatherPointY: Float = -1.0
    var gatherPointZ: Float = 0.0
    
    func data() -> [Float] {
        return [animationElapsedTime, animationTotalTime, gatherPointX, gatherPointY, gatherPointZ];
    }
    
    static func sizeInBytes() -> Int {
        return 5 * MemoryLayout<Float>.size
    }
}

其他自定义Transition动画的代码和之前一样,基本没动过。

总结

这种看似复杂的动画,可以把它拆解成几个简单的阶段,分开处理。对于每个阶段里复杂的运动,可以把运动拆分到不同的轴上,然后为每个轴上的运动规律推导公式。和上学时解题的思路还是很像的。使用网格制作动画相对于之前的点精灵,更加灵活,但是需要的顶点量也偏多。可以根据要做的效果斟酌使用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容