如何實現一個手賬 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% 功力。目前服務端完成的需求有:

  • 用戶的登陸註冊和鑑權;
  • 手賬及手賬本的建立、刪除和修改;
  • 貼紙的建立、刪除和修改。

若是不想與服務端進行交互,能夠直接該對應按鈕的點擊事件爲你想要展現的類,並註釋掉對應的服務端代碼便可。

項目地址:

參考連接

相關文章
相關標籤/搜索