开源项目-拼图验证控件TTGPuzzleVerify的实现

yqnet2013 7年前
   <p>最近抽空写了个拼图验证控件,用户可以通过水平、垂直,或者直接拖动拼图块,完成拼图图案,来完成验证。拼图块的形状可以自定义,默认提供了经典的拼图形状、圆形、正方形,整个拼图的图案也是支持从图片生成的。</p>    <p>CocoaPods: pod "TTGPuzzleVerify"<br>  </p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/dedea7ad9c0b10abe18d0955fd5820a7.gif"></p>    <p>接下来,说说TTGPuzzleVerify的整体设计思路和实现原理。</p>    <h2>分析</h2>    <h3>组成部分</h3>    <p>先直观上分析一下控件的组成部分:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c28e117d5b21c40477531b9be5ea8291.png"></p>    <p>可以直观看出,控件应该由三个部分组成:</p>    <ul>     <li>拼图块:就是从原图上 <strong>“抠”</strong> 出来的拼图形状的图片块</li>     <li>原始图片:拼图整体图案的图片</li>     <li>镂空背景:从原始图片上抠出拼图块以后,给用户提示用的,透明度更低的拼图块镂空背景</li>    </ul>    <h3>拆解步骤</h3>    <p>分析完了控件的组成,现在就来梳理一下控件的整个创建、交互流程。</p>    <p><img src="https://simg.open-open.com/show/508a6ecab1527e6b9074645de64dd1b2.png"></p>    <ul>     <li>输入:图片、拼图形状、拼图大小、镂空的位置、拼图块的起始位置</li>     <li>生成拼图路径:根据拼图类型,生成对应的贝塞尔曲线</li>     <li>从图片抠出拼图块、镂空:有了拼图路径,就可以抠出拼图块和镂空的部分</li>     <li>设置位置、样式:根据参数设置拼图块的初始位置、样式,如阴影、镂空的透明度等</li>     <li>用户拖动:响应用户的头动手势,移动拼图块,根据坐标判断是否完成拼图</li>     <li>回调:用户完成拼图后,要回调,让外部响应拼图完成事件</li>    </ul>    <h3>关键技术点</h3>    <p>从上面的步骤可以整理出来几个关键的技术点,如下:</p>    <p>拼图的路径生成</p>    <p>普通的形状,如正方形、圆形之类的还好,代码实现比较方便,但是复杂的拼图图案怎么办?</p>    <p>从图片上“抠出”拼图块、镂空</p>    <p>有了拼图的形状路径,如何从图片上抠出拼图块和镂空?</p>    <p>用户拖动拼图、完成拼图验证</p>    <p>抠出了拼图块,如何将用户的手势对应到移动拼图块,最终完成验证?</p>    <p>下面针对这三个点着重写下实现原理。</p>    <h2>实现</h2>    <h3>拼图的路径生成</h3>    <p>既然手写代码太难,那就从现有的图案生成代码。</p>    <p>1. 找SVG素材</p>    <p>既然要能随意改变大小,就要找矢量的图,Google “free svg”、“free icon”之类的,很容易就能找到免费的矢量图素材,例如TTGPuzzleVerify里面用的拼图形状就是从 <a href="/misc/goto?guid=4959731415508367383" rel="nofollow,noindex">iconmonstr.com</a> 找来的。</p>    <p>2. 编辑</p>    <p>直接下载回来的SVG还需要调整一下,如去除背景,我是用Sketch来编辑的。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ed8ec4c7eee3bf4205f5c4260f5e4af9.png"></p>    <p>3. 生成代码</p>    <p>拼图的路径本质上就是拼图的轮廓,在Sketch里面把拼图SVG的背景色等颜色删除,然后就是将其转换为代码。</p>    <p>这里可以用 PaintCode plugin for Sketch ,将Sketch的拼图矢量路径导出成代码。</p>    <p>4. 调整代码</p>    <p>直接生成的代码有时可能会不适用,可以人工调整下,然后就可以放到自己的类里面了:</p>    <pre>  <code class="language-objectivec">+ (UIBezierPath *)classicPuzzlePath {      static UIBezierPath *puzzleShape = nil;        static dispatch_once_t onceToken;      dispatch_once(&onceToken, ^{          puzzleShape = [UIBezierPath bezierPath];          [puzzleShape moveToPoint:CGPointMake(17.45, 71.16)];          // 此处省略很多行=。=,PaintCode生成的          [puzzleShape closePath];      });      return puzzleShape;  }  </code></pre>    <p>5. UIBezierPath拼图路径的大小、位置调整</p>    <p>直接导出的拼图路径的大小、位置是固定的,但是控件是要可以支持改变大小、位置的,所以,要对路径做变换,刚好 UIBezierPath 支持 CGAffineTransform 变换。</p>    <p>大小变换:</p>    <pre>  <code class="language-objectivec">// 大小变换,path是生成的原始路径,_puzzleSize是传入的拼图块大小  [path applyTransform:CGAffineTransformMakeScale(_puzzleSize.width / path.bounds.size.width,                                                  _puzzleSize.height / path.bounds.size.height)];  </code></pre>    <p>位置变换:</p>    <pre>  <code class="language-objectivec">// 位置变换,path是生成的原始路径,_puzzleBlankPosition是传入的拼图块、镂空位置  [path applyTransform:CGAffineTransformMakeTranslation(              _puzzleBlankPosition.x - path.bounds.origin.x,              _puzzleBlankPosition.y - path.bounds.origin.y)];  </code></pre>    <p>至此,拼图的路径就写好了。</p>    <h3>从图片上“抠出”拼图块、镂空</h3>    <p>1. 拼图块</p>    <p>有了UIBezierPath路径,抠出拼图块还是比较容易的,直接用 CAShapeLayer 来 <strong>“mask”</strong> 图片即可:</p>    <pre>  <code class="language-objectivec">// 拼图路径  UIBezierPath *puzzlePath = [self getNewScaledPuzzledPath];    // 创建mask layer  CAShapeLayer *puzzleMaskLayer = [CAShapeLayer new];  puzzleMaskLayer.frame = self.bounds;  puzzleMaskLayer.path = puzzlePath.CGPath;    // Mask到图片imageView上  _puzzleImageView.layer.mask = puzzleMaskLayer;  </code></pre>    <p>效果就是:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/07e8cd8fe2238a0aa18dc7d8636a11ae.png"></p>    <p>2. 镂空图片</p>    <p>抠出拼图块还是很容易的,但是“镂空”怎么办?</p>    <p>其实道理是一样的,都是 <strong>mask</strong> ,只不过mask的区域反过来而已。</p>    <p>CAShapeLayer 的 fillRule 就可以调整“填充的规则”,此处用 kCAFillRuleEvenOdd 就可以了,文档的解释如下:</p>    <p>Specifies the even-odd winding rule. Count the total number of path crossings. If the number of crossings is even, the point is outside the path. If the number of crossings is odd, the point is inside the path and the region containing it should be filled.</p>    <p>理解过来就是:区域内一点,往外划一条线,经过奇数个数的交点,则点在区域内,偶数个数的交点,则在区域外。</p>    <p>所以,只要在拼图路径的基础上,补充最外部的边框的路径即可:</p>    <pre>  <code class="language-objectivec">// 创建矩形边框路径  UIBezierPath *maskPath = [UIBezierPath bezierPathWithRect:self.bounds];  // 添加拼图路径  [maskPath appendPath:[UIBezierPath bezierPathWithCGPath:puzzlePath.CGPath]];  // 设置规则  maskPath.usesEvenOddFillRule = YES;    // 创建mask的layer  CAShapeLayer *frontMaskLayer = [CAShapeLayer new];  frontMaskLayer.frame = self.bounds;  frontMaskLayer.path = maskPath.CGPath;  frontMaskLayer.fillRule = kCAFillRuleEvenOdd; // 填充规则    // 设置mask  _frontImageView.layer.mask = frontMaskLayer;  </code></pre>    <p>效果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ed8ec4c7eee3bf4205f5c4260f5e4af9.png"></p>    <p>3. 镂空背景</p>    <p>有了拼图块、镂空,还差一块,就是镂空的“半透明背景”,其实这个“半透明背景”和拼图块是一模一样的,所以不再重复代码,唯一增加的就是设置其透明度。</p>    <p>4. 组合</p>    <p>将上面的组合到一起,就能形成最终的效果,从Reveal里面看到分层结构如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c67c8d15d2cd6dd4f63c0da4d150f1e6.png"></p>    <p>其实就是三张一样的图片,按照不同的mask规则做了处理,叠加起来。</p>    <h3>用户拖动拼图、完成拼图验证</h3>    <p>1. 拖动图片</p>    <p>有了上面的结构,拖动拼图的处理也就容易多了,只要移动最上层的拼图块就行了,这里直接用 UIPanGestureRecognizer 实现,移动的时候基于拼图的中心移动。</p>    <pre>  <code class="language-objectivec">// 创建手势  UIPanGestureRecognizer *panGestureRecognizer = [UIPanGestureRecognizer new];  [panGestureRecognizer addTarget:self action:@selector(onPanGesture:)];  [self addGestureRecognizer:panGestureRecognizer];    // 手势处理  - (void)onPanGesture:(UIPanGestureRecognizer *)panGestureRecognizer {      // 获取坐标      CGPoint panLocation = [panGestureRecognizer locationInView:self];      // 拼图中心偏移      CGPoint position = CGPointZero;      position.x = panLocation.x - _puzzleSize.width / 2;      position.y = panLocation.y - _puzzleSize.height / 2;      // 设置坐标      [self setPuzzlePosition:position];      // 判断、回调。。。  }  </code></pre>    <p>2. 验证用户的拼图是否完成</p>    <p>用户移动拼图时,只要判断拼图的坐标和镂空的位置是否一致就行。但是如果完全精确的判断,用户很难准确的把拼图拼对上,所以还要增加一定的 <strong>“tolerance”</strong> ,让拼图更加容易,同时暴漏出让外部设置:</p>    <pre>  <code class="language-objectivec">@property (nonatomic, assign) CGFloat verificationTolerance; // Verification tolerance, default is 8  </code></pre>    <p>判断的逻辑如下:</p>    <pre>  <code class="language-objectivec">// puzzlePosition是当前拼图块的位置,_puzzleBlankPosition是拼图镂空的位置  - (BOOL)isVerified {      return fabsf([self puzzlePosition].x - _puzzleBlankPosition.x) <= _verificationTolerance &&             fabsf([self puzzlePosition].y - _puzzleBlankPosition.y) <= _verificationTolerance;  }  </code></pre>    <p>3. 完成拼图</p>    <p>当用户完成拼图后,外部会得到 delegate 、 block 的回调响应,接着就可以“完成拼图验证”,对此向外提供一个方法: - (void)completeVerificationWithAnimation:(BOOL)withAnimation ,调用后拼图块将会移动回镂空位置,模拟出拼图完成的效果。</p>    <p>至此,TTGPuzzleVerify的关键实现技术点就是这些了~</p>    <p> </p>    <p> </p>