您好,登錄后才能下訂單哦!
前段時間對手帳類 app 的實現細節非常感興趣,遂萌生了想自己實現一個最小化的可行性產品。當然啦~既然是 MVP 模式下的產品,所以只實現了「功能」,但是在一些自己特別想要去「抄襲」的地方也下了一點功夫去追求 UI 的表現。
小時候,我是一個手抄報愛好者,四年級的時候班里組織了一個手抄報比賽,老師要求每位同學利用周末的時間做一份手抄報進行評比,主題自選。到現在我印象還非常深刻的是,我想了一個中午都不知道要選什么主題,在白紙上畫了一些東西后又全都擦掉了,弄臟了好幾張紙,最后畫出了一個地球,思路就慢慢打開了。
到了周一交給老師的時候,我不敢第一個交,我排在了隊伍的最后。老師接到我的手抄報后,居然說:“來來來,你們來看看什么叫手抄報”,我當時的心率達到了極高點,臉又紅又燙,站在老師身邊站也不是走也不是,尷尬的笑著,但內心卻極度自豪。
到了初中,班主任也讓大家利用周末的時間去做了一個手抄報,因為在小學的時候有了一點經驗,再加上到了初中那會兒基本上使用計算機來輔助完成各種任務也都鋪開了,我就尋思著能不能再做些創新。當時柯達傳出了倒閉的消息,這相當于是一代人的記憶吧~有時候我會跑到老房子里翻到各種膠卷,在陽光的照射下看著映射出的反射圖像。
結合這個事件,我就想到了利用「膠卷」風格的來闡述對保護鳥類的主題,從網上下載了一些各種鳥類的圖片,自己加工一下,終于把手抄報做好了交給老師。當交給老師的那一刻,老師愉悅的笑了,并拿著我的手抄報在講臺上給同學們展示,“大家看下,做的還不錯吧~嗯,挺好看!”。
高考完的那個暑假,《南國都市報》組織了一次中小學生手抄報大賽,當時我用堂弟的身份參加這個大賽,拿了三等獎,獎品是一張創新書店 500 元的購書卡。
以上就是我對手抄報或者說類似于手帳的這種手工畫的經歷了,我特別喜歡這種講述一個故事的方式,可以很好的把我想要表達的東西通過一些文字、圖片和畫的方式展現出來。
所以,當出現了手帳類 app 時,我迅速的下載進行使用,使用過程中確實達到了自己當初通過組織一些元素和文字來講述一件事的初衷。前段時間突發奇想,如果我能自己做一個手帳,順便去探究實現一個手帳 app 中需要注意的問題,那該多好啊!
首先,我把 App Store 中「手帳」關鍵詞下的搜索排名前 10 的 app 都進行了一番使用,總結出了一些手帳 app 通用點:
添加文字。可旋轉、放大縮小、旋轉字體;
添加照片。可旋轉翻轉、放大縮小、并具備簡單或者輔助的圖像修飾工具;
添加貼紙。使用一些繪制好的貼紙,操作與「添加照片」差不多;
模版。提供一套模版,用戶可以在這個模版規定好的區域進行內容添加;
提供無限長或寬的畫布。
基本上這些手帳 app 的功能就是這么多了,因為本著 MVP 的思路去做這個項目,所以也就沒有做到高保真的設計,直接抄了一個比較簡潔的手帳 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
,把「貼紙」添加到畫布上,實質上就是把?UIImageView
?給?addSubview
?到?UIView
?上。其次,手帳中追求的是對素材的控制,可旋轉放大是基本操作,而且前文也說過了,我們幾乎可以把「照片」和「文字」都認為是對「貼紙」的繼承,所以這就抽離出了「貼紙」本身是所以可提供交互組件的基類。
手帳類 app 對貼紙進行多手勢操作的流暢性是決定用戶留存率很大的一個因素。因此,我們再抽離一下手帳「貼紙」,把基礎手勢操作都移到更高一層的父類中去,貼紙中留下業務邏輯。手勢操作核心代碼邏輯如下:
//?pinchGesture?縮放手勢//?縮放的方法(文件私有)。??gesture手勢?:UI縮放手勢識別器@objcfileprivate?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旋轉手勢識別器@objcfileprivate?func?rotateImage(gesture:?UIRotationGestureRecognizer)?{ ????if?gesture.state?==?.changed?{ ????????transform?=?transform.rotated(by:?gesture.rotation) ????????//?0為弧度制(要跟角度轉換) ????????gesture.rotation?=?0 ????}}//?panGesture?拖拽/平移手勢//?平移的方法(文件私有)。??gesture手勢?:UI平移手勢識別器@objcfileprivate?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點擊手勢識別器)@objcfileprivate?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?inswitch?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?撤回@objcprivate?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?重做@objcprivate?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% 功力。目前服務端完成的需求有:
用戶的登錄注冊和鑒權;
手帳及手帳本的創建、刪除和修改;
貼紙的創建、刪除和修改。
如果不想與服務端進行交互,可以直接該對應按鈕的點擊事件為你想要展示的類,并注釋掉對應的服務端代碼即可。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。