Some time ago, I was very interested in the implementation details of the rival account app, so I wanted to achieve a minimum feasible product. Of course, since it is the MVP mode of the product, so only “function”, but in some places I particularly want to “copy” also put a little effort to pursue the PERFORMANCE of the UI.

preface

When I was young, I was a hand-copied newspaper enthusiast. When I was in the fourth grade, my class organized a hand-copied newspaper competition. The teacher required each student to make a hand-copied newspaper during the weekend for competition, and the theme was of their own choice. Until now, I still have a very deep impression. After thinking about the theme for a whole afternoon, I did not know what to choose. After drawing something on a white paper, I erased all of it, smeared several pieces of paper, and finally drew an earth.

When it came time to hand it in to the teacher on Monday, I dared not be the first to hand it in. I was at the end of the line. The teacher received my copy of the newspaper, incredibly said: “come to come, you have a look at what is called the copy of the newspaper”, my heart rate reached a very high point, my face was red and hot, standing beside the teacher is not to go, not to laugh awkwardly, but the heart was extremely proud.

In junior high school, the head teacher also let everyone use the weekend time to do a copy of the newspaper, because in primary school when I had a little experience, coupled with the junior high school at that time basically using computers to assist in completing various tasks are also spread, I thought can do some innovation. Sometimes I would go to my old house and look at all kinds of film, looking at the inverted color images in the sunlight.

Combined with this event, I came up with the idea of using the “film” style to illustrate the theme of bird protection. I downloaded some pictures of various birds from the Internet and processed them by myself. Finally, I made a copy of the newspaper and handed it to the teacher. When I handed it to the teacher at that moment, the teacher smiled happily, and took my copy of the newspaper on the platform to show the students, “look, everyone, it is not bad ~ mm, very good!” .

In the summer vacation after the college entrance examination, “Southern Metropolis Daily” organized a competition of copying newspapers for primary and secondary school students. I participated in the competition as a cousin and won the third prize, which was a 500 yuan book card from the Innovation Bookstore.

The above is my experience of hand-drawing such as hand-copied newspaper or similar to hand-ledger. I particularly like this way of telling a story, which can well show what I want to express through some words, pictures and paintings.

Therefore, when the mobile account app appeared, I quickly downloaded and used it, which really achieved my original intention of telling a story by organizing some elements and words. Some time ago, IT occurred to me that it would be great if I could make a personal ledger and explore the problems that need to be paid attention to in implementing a personal ledger APP.

design

First of all, I used the top 10 searched apps under the keyword “PDA” in the App Store and summarized some common points of PDA apps:

  • Add text. Rotate, zoom in and out, rotate the font;
  • Add photos. It can rotate and flip, zoom in and out, and has simple or auxiliary image modification tools;
  • Add stickers. Use a few painted stickers, similar to “Add photos”;
  • Template. Provide a template in which users can add content in the specified area;
  • Provides canvas of infinite length or width.

Basically, there are so many common functions of these notebook apps. Since we did this project with the idea of MVP, we did not achieve high fidelity design, and directly copied a relatively simple notebook APP design.

Technology stack

After determining the general function points that need to be done, I need to start to choose the technology stack, because after all, it is MVP product, not demo. My understanding of demo is “to achieve a certain function point”, and my understanding of MVP product is “a complete usable product under a certain stage”. There is no need to be too harsh on the details of things that come out in MVP mode, but the overall logic must be complete. Incomplete logic can be absent, but once it is present, it must be complete. The logical path covered can not be 100%, but the main logic must be fully covered.

The client

The development techniques of iOS app are as follows:

  • Pure native Swift development;
  • Network Request =>Alamofire, some simple data go directlyNSFileManagerFile persistence management;
  • UI components are all based onUIKitTo do; Social sharing goes beyond system sharing and does not integrate other SDKS;
  • Stickers, brushes, photos, and text are available on the module. In the process, I realized that “photos” and “text” were essentially stickers, which saved me a lot of trouble.

The service side

As a matter of fact, EVERY time I open a new side project, I have a rigid requirement that I should improve my technical level after finishing it. Actually, “growth” is very metaphysical. How can I define “growth”? I found the simplest idea for myself: do it with something new!

Therefore, I directly chose Vapor Progress on the service terminal without thinking, and wrote the service terminal through Swift, which was something I had always wanted to do before but could not find the opportunity to do, so I took this opportunity to get on the bus. As for why I didn’t choose Perfect, IN fact, I have never tried it personally, but I heard that Vapor API style is better than Swifty.

In the MVP of the first phase, there is little dependence on the server side, so the current architecture is relatively simple and can be used as soon as possible. Some details about the use of Vapor can be checked in this article, which will not cover the details of the use of Vapor in detail.

implementation

gestures

The core of a PDA is a “sticker”. How to pull the sticker out of storage and onto the canvas was solved, and so was most of the rest.

First of all, we need to make it clear that in this project, the canvas itself is also a UIView, and adding a sticker to the canvas is essentially adding a UIImageView to addSubview onto UIView. Secondly, the control of materials is sought in the notebook, rotatable amplification is the basic operation, and as mentioned above, we can almost regard “photo” and “text” as the inheritance of “sticker”, so it is removed from the “sticker” itself as the base class that can provide interactive components.

The ease with which a PDA app performs multiple gestures on a sticker is a big factor in retention. Therefore, we remove the “stickers” one more time and move the base gestures to the parent class at a higher level, leaving the business logic in the stickers. The core code logic of gesture operation is as follows:

// pinchGesture
// The method of scaling (file private). Gesture: UI zooming gesture recognizer
@objc
fileprivate func pinchImage(gesture: UIPinchGestureRecognizer) {
    // The current gesture state is changing
    if gesture.state == .changed {
        // The current matrix 2D transforms to scale through (gesture scaling parameters)
        transform = transform.scaledBy(x: gesture.scale, y: gesture.scale)
        // Restore to 1 (original size) without enlarging
        gesture.scale = 1}}// rotateGesture
// The method of rotation (file private). Gesture: THE UI rotates the gesture recognizer
@objc
fileprivate func rotateImage(gesture: UIRotationGestureRecognizer) {
    if gesture.state == .changed {
        transform = transform.rotated(by: gesture.rotation)
        // 0 is in radians.
        gesture.rotation = 0}}// panGesture drag/panGesture
// Pan the method (file private). Gesture: UI panning gesture recognizer
@objc
fileprivate func panImage(gesture: UIPanGestureRecognizer) {
    if gesture.state == .changed {
        // Convert coordinates to superview coordinates
        let gesturePosition = gesture.translation(in: superview)
        // Use the moving distance and the original position coordinates to calculate. Gestureposition. x is already positive and negative
        center = CGPoint(x: center.x + gesturePosition.x, y: center.y + gesturePosition.y)
        //. Zero is short for CGPoint(x: 0, y: 0)
        gesture.setTranslation(.zero, in: superview)
    }
}

// Double click action (UI click gesture recognizer)
@objc
fileprivate func doubleTapGesture(tap: UITapGestureRecognizer) {
    // Status double-click ends
    if tap.state == .ended {
        // Rotate 90 degrees
        let ratation = CGFloat(Double.pi / 2.0)
        // Change rotation Angle = previous rotation Angle + rotation
        transform = CGAffineTransform(rotationAngle: previousRotation + ratation)
        previousRotation += ratation
    }
}
Copy the code

The result is shown below:

Using UICollectionView as a sticker container, pass the icon image that corresponds to the index map to the parent view as a sticker object through the closure:

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) }Copy the code

The process from “sticker” to “canvas” is completed by receiving the sticker object in the superview through the implementation closure.

stickerComponentView.sticker = {
    $0.viewDelegate = self
    // The superview is centered
    $0.center = self.view.center
    $0.tag = self.stickerTag
    self.stickerTag += 1
    self.view.addSubview($0)
    // Add to the sticker collection
    self.stickerViews.append($0)}Copy the code

“Photos” and “text”

The toolbar at the bottom of the editing page was not well designed before. In principle, it should have been directly connected to a UITabBar, but it was also completed using UICollectionView. The operation of reading device photos is relatively simple, and there is no need to customize the album, so it is completed through the UIImagePicker of the system. Students who are interested in user-defined albums can read my article. The code details for the top toolbar are as follows:

// Click events at the bottom
collectionView.cellSelected = { cellIndex in
switch cellIndex {
    / / the background
    case 0:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        self.present(self.colorBottomView, animated: true, completion: nil)
    / / sticker
    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 })/ / text
    case 2:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = true
        self.bgImageView.image = brushView.drawImage()
        
        let vc = UNTextViewController(a)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)
        }
    / / photo
    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)
    / / brush
    case 4:
        self.stickerComponentView.isHidden = true
        
        brushView.isHidden = false
        self.bgImageView.image = nil
        self.view.bringSubviewToFront(brushView)
    default: break
}
Copy the code

Each module in the bottom toolbar is a UIView, and that’s not very well done either, so the best thing to do is to make a “tool container” based on UIWindow or UIViewController as a container for the UI content elements of each module, This eliminates the need to write so much view show/hide status code in the click event callback at the bottom of the toolbar.

Focus on “photos” part of the code block, the realization of UIImagePickerControllerDelegate agreement after the method is:

extension UNContentViewController: UIImagePickerControllerDelegate {
    /// Get the selected image from the image picker
    func imagePickerController(_ picker: UIImagePickerController,
                               didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        // Get the edited image
        let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage
        ifimage ! =nil {
            letwh = image! .size.width / image! .size.height// Initialize the sticker
            let sticker = UNStickerView(frame: CGRect(x: 150, y: 150, width: 100, height: 100 * wh))
            // Add a view
            self.view.addSubview(sticker)
            sticker.imgViewModel = UNStickerView.ImageStickerViewModel(image: image!)
            // Add to the sticker collection
            self.stickerViews.append(sticker)
    
            picker.dismiss(animated: true, completion: nil)}}}Copy the code

The text

The text module exposed to the superview is also an instantiated sticker object, but in the text VC you need to select the color, font, and size of the text. When I finished, I found that because the stickers can be enlarged and reduced by gestures, there is no need to choose……

One of the more difficult is the choice of text color. At first, I thought that I would just go straight to RGB color, but later I realized that if THERE are three channels directly through RGB, it would be very uncomfortable to adjust the color. Thinking of the HSB color mode used in the game “Crazy Pinball” before, I made a disk color picker. Later, in the process of thinking about the implementation details, I wrote this library EFColorPicker, which is very easy to use. After changing the UI, I directly used it, thanks EF!

The bubble view itself is a UIViewController, but several properties need to be set. The implementation process is more process-oriented. It is better to encapsulate these template-based codes into a “bubble view” class for the business side to use. However, due to time constraints, it has been copying, and the core code is as follows:

/// text size bubbles
private var sizeBottomView: UNBottomSizeViewController {
    get {
        let sizePopover = UNBottomSizeViewController()
        sizePopover.size = self.textView.font? .pointSize sizePopover.preferredContentSize =CGSize(width: 200, height: 100)
        sizePopover.modalPresentationStyle = .popover
        
        letsizePopoverPVC = sizePopover.popoverPresentationController sizePopoverPVC? .sourceView =self.bottomCollectionView sizePopoverPVC? .sourceRect =CGRect(x: bottomCollectionView! .cellCenterXs[1], y: 0, width: 0, height: 0) sizePopoverPVC? .permittedArrowDirections = .down sizePopoverPVC? .delegate =selfsizePopoverPVC? .backgroundColor = .white sizePopover.sizeChange = { sizein
            self.textView.font = UIFont(name: self.textView.font! .familyName, size: size) }return sizePopover
    }
}
Copy the code

Call present where the bubble view needs to pop up:

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}}Copy the code

The brush

During my internship in Didi, I wrote a paintbrush component (it was two years ago…). But this brush is based on the drawRect: method, which is very memory unfriendly, keep drawing, memory will continue to increase, this time using CAShapeLayer rewrite, the effect is not bad.

About the brush recall prior to drawRect: Each withdrawal is equivalent to redrawing, removing the withdrawn line from the drawing point set, but the implementation based on CAShapeLayer is different, because each stroke is directly generated in the layer. If you want to undo, you have to regenerate the current layer.

So what I ended up doing was generating an image for each stroke and saving it in the array, and when I did the recall, I replaced the last element in the recall array with the canvas content that was currently being drawn, and I removed that element from the recall array.

If you have a retraction, you have to redo it. The redo is to prevent retracement, just like retracement. Create a redo array and append each image removed from the undo array. The following is the core code to undo the redo:

/ / undo withdrawn
@objc
private func undo(a) {
    // undoDatas number of retractable sets
    guard undoDatas.count! =0 else { return }
    
    // If there is only one data in the recall set, it is null after the recall
    if undoDatas.count= =1 {
        // Redo redo append
        redoDatas.append(undoDatas.last!)
        // Undo clearing
        undoDatas.removeLast()
        // Clear the image view
        bgView.image = nil
    } else {
        // redo 3
        redoDatas.append(undoDatas.last!)
        // Remove 3 from undo
        undoDatas.removeLast()
        // Clear the image view
        bgView.image = nil
        // Give 2 to the image view
        bgView.image = UIImage(data: undoDatas.last!) }}/ / redo redo
@objc
private func redo(a) {
    if redoDatas.count > 0 {
        // Assign first, then remove (last for redo view)
        bgView.image = UIImage(data: redoDatas.last!)
        // Redo last to undo retract array
        undoDatas.append(redoDatas.last!)
        // Remove last from redo redo
        redoDatas.removeLast()
    }
}
Copy the code

Here’s the way I think about eraser. In real life, when using an eraser, you erase the handwriting already written on the paper and change it to the project. In fact, an eraser is also a kind of paintbrush but a paintbrush without color. There are two ways of thinking:

  • Handwriting is added directly tocontentLayerUp. Now I need to do one for the erasermask, the eraser handwriting path and base map to make amask, so the content left by the eraser handwriting is the content of the base image;
  • The handwriting is added to the other onelayerOn. This case can be set directly to the eraserlayerThe background color is equivalent toclearColor.

I haven’t tried the second method, but the first method is very OK.

conclusion

The above is the minimum feasible product of the PDA APP. Of course, there are still many details, such as the code ideas of the server side. Because the server was still based on the product and the design was not good, it was the first time for me to use Vapor for development and only gave full play to 10% of Vapor. The current requirements of the server are as follows:

  • User login registration and authentication;
  • The creation, deletion and modification of ledger and ledger books;
  • Create, delete and modify stickers.

If you do not want to interact with the server, you can specify the button click event as the class you want to display and comment out the corresponding server code.

Project Address:

  • Unicorn-iOS
  • Unicorn-Server

Refer to the link

  • WHStoryMaker
  • LyEditImageView
  • phimpme-iOS
  • TouchDraw