如何实现一个手账 app

前段时间对手账类 app 的实现细节很是感兴趣,遂萌生了想本身实现一个最小化的可行性产品。固然啦~既然是 MVP 模式下的产品,因此只实现了「功能」,可是在一些本身特别想要去「抄袭」的地方也下了一点功夫去追求 UI 的表现。git

前言

小时候,我是一个手抄报爱好者,四年级的时候班里组织了一个手抄报比赛,老师要求每位同窗利用周末的时间作一份手抄报进行评比,主题自选。到如今我印象还很是深入的是,我想了一个中午都不知道要选什么主题,在白纸上画了一些东西后又全都擦掉了,弄脏了好几张纸,最后画出了一个地球,思路就慢慢打开了。github

到了周一交给老师的时候,我不敢第一个交,我排在了队伍的最后。老师接到个人手抄报后,竟然说:“来来来,大家来看看什么叫手抄报”,我当时的心率达到了极高点,脸又红又烫,站在老师身边站也不是走也不是,尴尬的笑着,但心里却极度自豪。swift

到了初中,班主任也让你们利用周末的时间去作了一个手抄报,由于在小学的时候有了一点经验,再加上到了初中那会儿基本上使用计算机来辅助完成各类任务也都铺开了,我就寻思着能不能再作些创新。当时柯达传出了倒闭的消息,这至关因而一代人的记忆吧~有时候我会跑到老房子里翻到各类胶卷,在阳光的照射下看着映射出的反色图像。数组

结合这个事件,我就想到了利用「胶卷」风格的来阐述对保护鸟类的主题,从网上下载了一些各类鸟类的图片,本身加工一下,终于把手抄报作好了交给老师。当交给老师的那一刻,老师愉悦的笑了,并拿着个人手抄报在讲台上给同窗们展现,“你们看下,作的还不错吧~嗯,挺好看!”。网络

高考完的那个暑假,《南国都市报》组织了一次中小学生手抄报大赛,当时我用堂弟的身份参加这个大赛,拿了三等奖,奖品是一张创新书店 500 元的购书卡。闭包

以上就是我对手抄报或者说相似于手账的这种手工画的经历了,我特别喜欢这种讲述一个故事的方式,能够很好的把我想要表达的东西经过一些文字、图片和画的方式展示出来。架构

因此,当出现了手账类 app 时,我迅速的下载进行使用,使用过程当中确实达到了本身当初经过组织一些元素和文字来说述一件事的初衷。前段时间突发奇想,若是我能本身作一个手账,顺便去探究实现一个手账 app 中须要注意的问题,那该多好啊!app

设计

首先,我把 App Store 中「手账」关键词下的搜索排名前 10 的 app 都进行了一番使用,总结出了一些手账 app 通用点:ide

  • 添加文字。可旋转、放大缩小、旋转字体;
  • 添加照片。可旋转翻转、放大缩小、并具有简单或者辅助的图像修饰工具;
  • 添加贴纸。使用一些绘制好的贴纸,操做与「添加照片」差很少;
  • 模版。提供一套模版,用户能够在这个模版规定好的区域进行内容添加;
  • 提供无限长或宽的画布。

基本上这些手账 app 的共性功能就是这么多了,由于本着 MVP 的思路去作这个项目,因此也就没有作到高保真的设计,直接抄了一个比较简洁的手账 app 设计。工具

体验过的手账 app 集合(部分)

技术栈

肯定好了本身要实现的大概须要作的功能点后,就须要开始去选择技术栈,由于要作的毕竟是 MVP 产品而不是 demo,我对 demo 的理解是「实现某个功能点」,对 MVP 产品的理解是「某个阶段下的完整可用的产品」,MVP 模式下出来的东西细节出现一些问题不用太过于苛责,但总体的逻辑上必定是要完整的,不完整的逻辑能够没有,可是一旦有了就要是完整的,覆盖的逻辑路径也能够不是 100%,但主逻辑必定要全覆盖。

客户端

iOS app 的开发技术点以下:

  • 纯原生 Swift 开发;
  • 网络请求 => Alamofire,一些简单的数据直接走 NSFileManager 进行文件持久化管理;
  • UI 组件全都基于 UIKit 去作;社会化分享走系统分享,不集成其它 SDK;
  • 模块上提供「贴纸」、「画笔」、「照片」和「文字」。作的过程当中发现其实「照片」和「文字」本质上来讲也是贴纸,省了很多事。

客户端架构

服务端

其实我对本身每新开一个 side project 都有一个硬性要求,作完后要对本身的技术水平有增加,其实「增加」这个东西很玄学,怎么定义「增加」对吧?我给本身找到了一个最简单的思路:用新的东西去完成它!

所以在服务端上我就直接无脑的选择了 Vapor 进行,经过 Swift 去写服务端这是我以前一直想作但找不到时机去作的事情,借此机会就上车了。至于为何不是选 Perfect,其实我我的没有去动手实践过,只是听大佬们说 Vapor 的 API 风格比较 Swifty 一些。

服务端架构

在第一期的 MVP 中对服务端的依赖不大,因此目前的架构比较简单,达到能用便可就完事了~关于 Vapor 的一些使用细节,能够在个人这篇文章中进行查看,本文将再也不细述 Vapor 使用细节。

实现

手势

对于手账来讲,最核心的一个就是**「贴纸」**。如何把贴纸从存储中拉出来放到画布上,这一步解决了,后续大部份内容也都解决了。

首先,咱们须要明确一点,在这个项目中,「画布」自己也是个 UIView,把「贴纸」添加到画布上,实质上就是把 UIImageViewaddSubviewUIView 上。其次,手账中追求的是对素材的控制,可旋转放大是基本操做,并且前文也说过了,咱们几乎能够把「照片」和「文字」都认为是对「贴纸」的继承,因此这就抽离出了「贴纸」自己是因此可提供交互组件的基类。

手账类 app 对贴纸进行多手势操做的流畅性是决定用户留存率很大的一个因素。所以,咱们再抽离一下手账「贴纸」,把基础手势操做都移到更高一层的父类中去,贴纸中留下业务逻辑。手势操做核心代码逻辑以下:

// pinchGesture 缩放手势
// 缩放的方法(文件私有)。 gesture手势 :UI缩放手势识别器
@objc
fileprivate func pinchImage(gesture: UIPinchGestureRecognizer) {
    // 当前手势 状态 改变中
    if gesture.state == .changed {
        // 当前矩阵2D变换 缩放经过(手势缩放的参数)
        transform = transform.scaledBy(x: gesture.scale, y: gesture.scale)
        // 要复原到1(原尺寸),不要叠加放大
        gesture.scale = 1
    }
}

// rotateGesture 旋转手势
// 旋转的方法(文件私有)。 gesture手势 :UI旋转手势识别器
@objc
fileprivate func rotateImage(gesture: UIRotationGestureRecognizer) {
    if gesture.state == .changed {
        transform = transform.rotated(by: gesture.rotation)
        // 0为弧度制(要跟角度转换)
        gesture.rotation = 0
    }
}

// panGesture 拖拽/平移手势
// 平移的方法(文件私有)。 gesture手势 :UI平移手势识别器
@objc
fileprivate func panImage(gesture: UIPanGestureRecognizer) {
    if gesture.state == .changed {
        // 坐标转换至父视图坐标
        let gesturePosition = gesture.translation(in: superview)
        // 用移动距离与原位置坐标计算。 gesturePosition.x 已经带正负了
        center = CGPoint(x: center.x + gesturePosition.x, y: center.y + gesturePosition.y)
        // .zero 为 CGPoint(x: 0, y: 0)的简写, 位置坐标回0
        gesture.setTranslation(.zero, in: superview)
    }
}

// 双击动做(UI点击手势识别器)
@objc
fileprivate func doubleTapGesture(tap: UITapGestureRecognizer) {
    // 状态 双击结束后
    if tap.state == .ended {
        // 翻转 90度
        let ratation = CGFloat(Double.pi / 2.0)
        // 变换 旋转角度 = 以前的旋转角度 + 旋转
        transform = CGAffineTransform(rotationAngle: previousRotation + ratation)
        previousRotation += ratation
    }
}
复制代码

实现的效果下图所示:

对贴纸增长的手势操做

使用 UICollectionView 做为贴纸容器,经过闭包把点击事件对应索引映射的 icon 图片实例化为贴纸对象传递给父视图:

collectionView.cellSelected = { cellIndex in
    let stickerImage = UIImage(named: collectionView.iconTitle + "\(cellIndex)")
    let sticker = UNStickerView()
    sticker.width = 100
    sticker.height = 100
    sticker.imgViewModel = UNStickerView.ImageStickerViewModel(image: stickerImage!)
    self.sticker?(sticker)
}
复制代码

在父视图中经过实现闭包接收贴纸对象,这样就完成了**「贴纸」到「画布」**的全流程。

stickerComponentView.sticker = {
    $0.viewDelegate = self
    // 父视图居中
    $0.center = self.view.center
    $0.tag = self.stickerTag
    self.stickerTag += 1
    self.view.addSubview($0)
    // 添加到贴纸集合中
    self.stickerViews.append($0)
}
复制代码

「照片」和「文字」

手账编辑页面的底部工具栏以前没作好设计,按道理来讲,应该直接上一个 UITabBar 便可完事,但最终也使用了 UICollectionView 完成。读取设备照片操做比较简单,不须要自定义相册,因此经过系统的 UIImagePicker 完成,对自定义相册感兴趣的同窗能够看个人这篇文章。顶部工具栏的代码细节以下所示:

// 底部的点击事件
collectionView.cellSelected = { cellIndex in
switch cellIndex {
    // 背景
    case 0:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        self.present(self.colorBottomView, animated: true, completion: nil)
    // 贴纸
    case 1:
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        self.stickerComponentView.isHidden = false
        UIView.animate(withDuration: 0.25, animations: {
            self.stickerComponentView.bottom = self.bottomCollectionView!.y
        })
    // 文字
    case 2:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        let vc = UNTextViewController()
        self.present(vc, animated: true, completion: nil)
        vc.complateHandler = { viewModel in
            let stickerLabel = UNStickerView(frame: CGRect(x: 150, y: 150, width: 100, height: 100))
            self.view.addSubview(stickerLabel)
            stickerLabel.textViewModel = viewModel
            self.stickerViews.append(stickerLabel)
        }
    // 照片
    case 3:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        self.imagePicker.delegate = self
        self.imagePicker.sourceType = .photoLibrary
        self.imagePicker.allowsEditing = true
        self.present(self.imagePicker, animated: true, completion: nil)
    // 画笔
    case 4:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = false
        self.bgImageView.image = nil
        self.view.bringSubviewToFront(brushView)
    default: break
}
复制代码

底部工具栏的每个模块都是一个 UIView,这部分作的也不太好,最佳的作法应该是基于 UIWindow 或者 UIViewController 作一个「工具容器」做为各个模块 UI 内容元素的容器,经过这种作法就能够免去在底部工具栏的点击事件回调中写这么多的视图显示/隐藏的状态代码。

关注「照片」部分的代码块,实现 UIImagePickerControllerDelegate 协议后的方法为:

extension UNContentViewController: UIImagePickerControllerDelegate {
    /// 从图片选择器中获取选择到的图片
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        // 获取到编辑后的图片
        let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage
        if image != nil {
            let wh = image!.size.width / image!.size.height
            // 初始化贴纸
            let sticker = UNStickerView(frame: CGRect(x: 150, y: 150, width: 100, height: 100 * wh))
            // 添加视图
            self.view.addSubview(sticker)
            sticker.imgViewModel = UNStickerView.ImageStickerViewModel(image: image!)
            // 添加到贴纸集合中
            self.stickerViews.append(sticker)
    
            picker.dismiss(animated: true, completion: nil)
        }
    }
}
复制代码

文字

文字模块暴露给父视图也是一个实例化后的贴纸对象,不过在文字 VC 里须要对文字进行颜色、字体和字号的选择。作完了才发现其实由于贴纸是能够经过手势进行放大和缩小的,不必作字号的选择......

文字模块功能全览

其中比较费劲的是对文字颜色的选择,刚开始我想的直接上 RGB 调色就算了,后来想到若是直接经过 RGB 有三个通道,调转机来很是的难受。想到以前在作《疯狂弹球》这个游戏时使用的 HSB 颜色模式,作一个圆盘颜色选择器,后来在思考实现细节的过程当中了这么 EF 写的这个库 EFColorPicker,很是好用,改了改 UI 后直接拿来用了,感谢 EF !

「气泡视图」的自己是个 UIViewController,可是须要对其几个属性进行设置。其实现流程比较流程化,比较好的作法是封装一下,把这些模版化的代码变成一个「气泡视图」类供业务方使用,但由于时间关系就一直在 copy,核心代码以下:

/// 文字大小气泡
private var sizeBottomView: UNBottomSizeViewController {
    get {
        let sizePopover = UNBottomSizeViewController()
        sizePopover.size = self.textView.font?.pointSize
        sizePopover.preferredContentSize = CGSize(width: 200, height: 100)
        sizePopover.modalPresentationStyle = .popover
        
        let sizePopoverPVC = sizePopover.popoverPresentationController
        sizePopoverPVC?.sourceView = self.bottomCollectionView
        sizePopoverPVC?.sourceRect = CGRect(x: bottomCollectionView!.cellCenterXs[1], y: 0, width: 0, height: 0)
        sizePopoverPVC?.permittedArrowDirections = .down
        sizePopoverPVC?.delegate = self
        sizePopoverPVC?.backgroundColor = .white
        
        sizePopover.sizeChange = { size in
            self.textView.font = UIFont(name: self.textView.font!.familyName, size: size)
        }
        
        return sizePopover
    }
}
复制代码

在须要弹出该气泡视图的地方经过 present 便可调用:

collectionView.cellSelected = { cellIndex in
    switch cellIndex {
    case 0: self.present(self.fontBottomView,
                            animated: true,
                            completion: nil)
    case 1: self.present(self.sizeBottomView,
                            animated: true,
                            completion: nil)
    case 2: self.present(self.colorBottomView,
                            animated: true,
                            completion: nil)
    default: break
    }
}
复制代码

画笔

以前在滴滴实习时,写过一个关于画笔的组件(竟然已经两年前了...),可是这个画笔是基于 drawRect: 方法去作的,对于内存十分不友好,一直画下去,内存就会一直涨,这回采用了 CAShapeLayer 重写了一个,效果还不错。

画笔

关于画笔的撤回以前基于 drawRect: 的方式去作就会很是简单,每一次的撤回至关于重绘一次,把被撤回的线从绘制点数组中 remove 掉就行了,但基于 CAShapeLayer 实现不太同样,由于其每一笔都是直接生成在 layer 中了,若是须要撤回就得把当前从新生成 layer

因此最后个人作法是每画一笔都去生成一张图片保存到数组中,当执行撤回操做时,就把撤回数组中的最后一个元素替换当前正在的绘制画布内容,并从撤回数组中移除这个元素。

有了撤回,那也要把重作给上了。重作的就是防止撤回,作法跟撤回相似。再建立一个重作数组,把每次从撤回数组中移除掉的图片都 append 到重作数组中便可。如下为撤回重作的核心代码:

// undo 撤回
@objc
private func undo() {
    // undoDatas 可撤回集合 数量
    guard undoDatas.count != 0 else { return }
    
    // 若是是撤回集合中只有 1 个数据,则说明撤回后为空
    if undoDatas.count == 1 {
        // 重作 redo append 添加
        redoDatas.append(undoDatas.last!)
        // 撤回 undo 清空
        undoDatas.removeLast()
        // 清空图片视图
        bgView.image = nil
    } else {
        // 把 3 给 redo
        redoDatas.append(undoDatas.last!)
        // 从 undo 移除 3. 还剩 2 1
        undoDatas.removeLast()
        // 清空图片视图
        bgView.image = nil
        // 把 2 给图片视图
        bgView.image = UIImage(data: undoDatas.last!)
    }
}

// redo 重作
@objc
private func redo() {
    if redoDatas.count > 0 {
        // 先赋值,再移除(redo的last给图片视图)
        bgView.image = UIImage(data: redoDatas.last!)
        // redo的last 给 undo撤回数组
        undoDatas.append(redoDatas.last!)
        // 从redo重作 移除last
        redoDatas.removeLast()
    }
}
复制代码

关于橡皮的思路我是这么考虑的。按照现实生活中状况,使用橡皮时是把已经写在纸上的笔迹给擦除,换到项目中来看,其实橡皮也是一种画笔只不过是没有颜色的画笔罢了,而且能够有两种思路:

  • 笔迹直接加在 contentLayer 上,此时须要对橡皮作一个 mask,把橡皮笔迹的路径和底图作一个 mask,这样橡皮笔迹留下的内容就是底图的内容了;
  • 笔迹加在另一个 layer 上。这种状况能够直接给橡皮设置成该 layer 的背景色,至关于 clearColor

第二种作法我没试过,可是第一种作法是很是 OK 的。

总结

以上就是手账 app 的最小可行性产品了,固然还有不少细节都没有展开,好比服务端部分的代码思路。由于服务端仍是围绕产品出发,设计上也不太好,是我第一次使用 Vapor 进行开发,只发挥出了 Vapor 的 10% 功力。目前服务端完成的需求有:

  • 用户的登陆注册和鉴权;
  • 手账及手账本的建立、删除和修改;
  • 贴纸的建立、删除和修改。

若是不想与服务端进行交互,能够直接该对应按钮的点击事件为你想要展现的类,并注释掉对应的服务端代码便可。

项目地址:

参考连接

相关文章
相关标签/搜索