如何實現一個手帳 App

PJHubs發表於2019-08-25

前段時間對手帳類 app 的實現細節非常感興趣,遂萌生了想自己實現一個最小化的可行性產品。當然啦~既然是 MVP 模式下的產品,所以只實現了「功能」,但是在一些自己特別想要去「抄襲」的地方也下了一點功夫去追求 UI 的表現。

前言

小時候,我是一個手抄報愛好者,四年級的時候班裡組織了一個手抄報比賽,老師要求每位同學利用週末的時間做一份手抄報進行評比,主題自選。到現在我印象還非常深刻的是,我想了一箇中午都不知道要選什麼主題,在白紙上畫了一些東西后又全都擦掉了,弄髒了好幾張紙,最後畫出了一個地球,思路就慢慢開啟了。

到了週一交給老師的時候,我不敢第一個交,我排在了隊伍的最後。老師接到我的手抄報後,居然說:“來來來,你們來看看什麼叫手抄報”,我當時的心率達到了極高點,臉又紅又燙,站在老師身邊站也不是走也不是,尷尬的笑著,但內心卻極度自豪。

到了初中,班主任也讓大家利用週末的時間去做了一個手抄報,因為在小學的時候有了一點經驗,再加上到了初中那會兒基本上使用計算機來輔助完成各種任務也都鋪開了,我就尋思著能不能再做些創新。當時柯達傳出了倒閉的訊息,這相當於是一代人的記憶吧~有時候我會跑到老房子裡翻到各種膠捲,在陽光的照射下看著對映出的反色影象。

結合這個事件,我就想到了利用「膠捲」風格的來闡述對保護鳥類的主題,從網上下載了一些各種鳥類的圖片,自己加工一下,終於把手抄報做好了交給老師。當交給老師的那一刻,老師愉悅的笑了,並拿著我的手抄報在講臺上給同學們展示,“大家看下,做的還不錯吧~嗯,挺好看!”。

高考完的那個暑假,《南國都市報》組織了一次中小學生手抄報大賽,當時我用堂弟的身份參加這個大賽,拿了三等獎,獎品是一張創新書店 500 元的購書卡。

以上就是我對手抄報或者說類似於手帳的這種手工畫的經歷了,我特別喜歡這種講述一個故事的方式,可以很好的把我想要表達的東西通過一些文字、圖片和畫的方式展現出來。

所以,當出現了手帳類 app 時,我迅速的下載進行使用,使用過程中確實達到了自己當初通過組織一些元素和文字來講述一件事的初衷。前段時間突發奇想,如果我能自己做一個手帳,順便去探究實現一個手帳 app 中需要注意的問題,那該多好啊!

設計

首先,我把 App Store 中「手帳」關鍵詞下的搜尋排名前 10 的 app 都進行了一番使用,總結出了一些手帳 app 通用點:

  • 新增文字。可旋轉、放大縮小、旋轉字型;
  • 新增照片。可旋轉翻轉、放大縮小、並具備簡單或者輔助的影象修飾工具;
  • 新增貼紙。使用一些繪製好的貼紙,操作與「新增照片」差不多;
  • 模版。提供一套模版,使用者可以在這個模版規定好的區域進行內容新增;
  • 提供無限長或寬的畫布。

基本上這些手帳 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% 功力。目前服務端完成的需求有:

  • 使用者的登入註冊和鑑權;
  • 手帳及手帳本的建立、刪除和修改;
  • 貼紙的建立、刪除和修改。

如果不想與服務端進行互動,可以直接該對應按鈕的點選事件為你想要展示的類,並註釋掉對應的服務端程式碼即可。

專案地址:

參考連結

優秀的人遵守規則,頂尖的人創造規則

相關文章