Thursday, April 4, 2019

Animation: moving contents with constraints in Swift

I'm developing a pet project, named MyCalculator, using Swift 3. In portrait mode, the app shows only the basic operation buttons but when the user swipes to right, the basic operation buttons should slide out and the advance operation buttons should slide in as shown in the image below.


I put the basic operation buttons in one stackview and the advanced operation buttons in another stackview. Then, I put those two stackviews in another stackview together, named Buttons Stack View. I added two constraints for the Buttons Stack View to define its leading edge and trailing edge equal to the ViewController's view's leading edge and trailing edge, respectively.


To make the basic operation buttons disappear and the advanced operation buttons appear, I changed to constant property of the constraint as following:

self.buttonsStackviewLeading.constant = self.buttonsStackviewLeading.constant + view.bounds.width
self.buttonsStackviewTrailing.constant = self.buttonsStackviewTrailing.constant + (-1 * view.bounds.width)
    
But, I want to do more than that. i want to container stackview of those buttons move according to the user's finger movement point by point. This requires more effort and I put it in the steps below.

1. Create required outlets 


Create outlets for the Buttons Stack View and it's leading and trailing constraints as following:

    @IBOutlet weak var buttonsStackView: UIStackView!
    @IBOutlet weak var buttonsStackviewLeading: NSLayoutConstraint!
    @IBOutlet weak var buttonsStackviewTrailing: NSLayoutConstraint!
    

2. Initializing UIPanGestureRecognizer 


- Add the following lines to the ViewController's viewDidLoad() method.

let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: #selector(panGestureHandler(_:)))
buttonsStackView.addGestureRecognizer(panGestureRecognizer)
    

3. Handle the Pan Gesture Recognizer

Add these properties to the ViewController

    private var beganPoint:CGPoint?
    private var previousPoint:CGPoint?
    private var beganStackviewFrameMinX:CGFloat?
    private var beganStackviewFrameMaxX:CGFloat?
    private static let swipeFastMinPointsPerSecond = CGFloat(400)
    private static let maxPointsToMoveWhenSwipeFast = CGFloat(20)
    

Then add the following method:

    @objc public func panGestureHandler(_ recognizer:UIPanGestureRecognizer) {
        // Don't use UIDevice.current.orientation.isLandscape here
        // because its value can be 'flat (face-up or face-down)'
        // then the user would still be able to swipe when the app's UI is in landscape.
        if UIApplication.shared.statusBarOrientation.isLandscape {
            return
        }
        
        let currentPoint = recognizer.translation(in: view)
        let halfScreenWidth = view.bounds.width / 2
        
        if recognizer.state == .began {
            previousPoint = currentPoint
            beganStackviewFrameMinX = buttonsStackView.frame.minX
            beganStackviewFrameMaxX = buttonsStackView.frame.maxX
        } else if recognizer.state == .changed {            
            var changedPointsDiff = currentPoint.x - (previousPoint?.x)!
            previousPoint = currentPoint
            
            if swipedFast(recognizer) {
                changedPointsDiff = recognizer.velocity(in: view).x < 0 ? (-1 * ViewController.maxPointsToMoveWhenSwipeFast) : ViewController.maxPointsToMoveWhenSwipeFast
            }
            
            // If in case like the basic buttons are showing
            // and the user swipes/drags from right to left,
            if beganStackviewFrameMinX! == view.bounds.minX && buttonsStackviewLeading.constant > halfScreenWidth
                || beganStackviewFrameMaxX! == view.bounds.maxX && buttonsStackviewTrailing.constant > halfScreenWidth {
                
                // so the user can't drag those buttons more than half the screen
                return
            }
            
            buttonsStackviewLeading.constant = buttonsStackviewLeading.constant + changedPointsDiff
            buttonsStackviewTrailing.constant = buttonsStackviewTrailing.constant + (-1 * changedPointsDiff)
        } else if recognizer.state == .ended {
            
            let totalPointsMoved = buttonsStackView.frame.minX - beganStackviewFrameMinX!
            var finalPointsToMove:CGFloat = -1 * totalPointsMoved // Should move back to original position by default

            let swipedPointsPerSecond = recognizer.velocity(in: view).x
            if swipedFast(recognizer) {
                
                // When the user swipes fast, slide in advanced buttons or basic buttons.
                // It doesn't have to be a long swipe/drag.
                if beganStackviewFrameMinX! == view.bounds.minX && swipedPointsPerSecond < 0 {
                    finalPointsToMove = -1 * view.bounds.width - totalPointsMoved
                } else if beganStackviewFrameMaxX! == view.bounds.maxX && swipedPointsPerSecond > 0 {
                    finalPointsToMove =  view.bounds.width - totalPointsMoved
                }
            } else if beganStackviewFrameMinX == view.bounds.minX && totalPointsMoved > 0
                || beganStackviewFrameMaxX == view.bounds.maxX && totalPointsMoved < 0
                || abs(totalPointsMoved) < halfScreenWidth {
                
                // If the basic buttons are showing and the user swiped to left,
                // just move them to left a little bit and then move them back to the original position.
                finalPointsToMove = -1 * totalPointsMoved
            } else if abs(totalPointsMoved) > halfScreenWidth {
                
                // If dragged more than half the screen width,
                // show advanced/basic buttons
                finalPointsToMove = view.bounds.maxX - abs(totalPointsMoved)
                finalPointsToMove = totalPointsMoved > 0 ? finalPointsToMove : -1 * finalPointsToMove
            }
            
            self.buttonsStackviewLeading.constant = self.buttonsStackviewLeading.constant + finalPointsToMove
            self.buttonsStackviewTrailing.constant = self.buttonsStackviewTrailing.constant + (-1 * finalPointsToMove)
            UIView.animate(withDuration: 0.3, animations: {
                self.view.layoutIfNeeded()
            }, completion: {(finished:Bool) in
                if finished {
                    if self.buttonsStackviewLeading.constant >= 0 {
                        self.showedAdvancedOperationsInPortrait = true
                    } else {
                        self.showedAdvancedOperationsInPortrait = false
                    }
                }
            })
        }
    }
    

And add this helper method too

    private func swipedFast(_ recognizer:UIPanGestureRecognizer) -> Bool {
        return abs(recognizer.velocity(in: view).x) > ViewController.swipeFastMinPointsPerSecond
    }
    

The recognizer.velocity() method below returns the number of points per second in which the user has swiped the screen. The amount of points the user has swiped across the screen might be only 30 but the velocity might be 3000 points per second.


Note (AffineTransform)


We can also use AffineTransform struct to move the buttons as following:
    
buttonsStackView.transform = buttonsStackView.transform.translatedBy(x: pointsToMove, y: buttonsStackView.frame.minY)
    
But, using AffineTranform does not affect any constraints then it would be very hard to manage as the constraints and the AffineTransform might be conflicted with each other. I tried it in my example project too and it didn't work well. I had constraints to change the location of the stackview according to the screen's orientation. Using the AffineTransform struct worked in portrait mode but when the screen rotated to landscape, some buttons went off the screen.

I think if it's a slide-in popup view like in this post, using AfflineTransform is okay because the popup view usually stay on top of other views and it does not need constraints relative to the other views so changing its location and size does not really affect the others.


How to detect device's orientation in Swift

There are two ways to detect a device's orientation:

1). The UIDevice.current.orientation property (instance of UIDeviceOrientation class:
- isFlat: when the device is held upward (toward the sky) or downward (toward the ground)
- isLandscape: when the device is held upright and the home button is on the right/left
- isPortrait: when the device is held upright and the home button is at the bottom/top

2). The UIApplication.shared.statusBarOrientation property:
- isLandscape
- isPortrait


Using Swift 3, the status bar is hidden by default in my app. However, the isLandscape and isPortrait still work correctly.

Which one should we use?
It depends on circumstances. In my case, I want to enable swipe gesture in portrait mode but disable it in landscape mode so UIApplication.shared.statusBarOrientation.isLandscape is the right one. If my device is firstly in landscape mode and then i put it on the table (face up), the swipe gesture should still be disable. The UIDevice.current.orientation.isLandscape property would return false and then the user would be able to swipe.




Monday, April 1, 2019

Swift: Dynamically Change A Label's Font Size Based On Device's Screen Size

Different devices have different screen sizes or resolutions, for example, iPhone SE has 640x1136 resolution (pixels) but iPhone 6 has 750x1334 resolution (pixels). We can use UIKit framework to obtain information about the screen resolutions.

Points are logical unit used in iOS. The number of pixels per point varies from devices. The more pixels per point, the clearer the icons and texts.


When developing an app to run on different devices, the text's size must be able to dynamically change or the text might look fine on one device but looks too big or too small on another.

Below is a custom label in which its font can change based on the device's screen size.
    
import Foundation
import UIKit

public class AdaptiveFontLabel: UILabel {
    private let iphone6HeightInPoints:CGFloat = 667
    
    var utfText:String?
    
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        
        // On landscape mode, height is less than width
        let heightDiff = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) / iphone6HeightInPoints
        let newFontSize = font.pointSize * heightDiff
        
        font = UIFont(descriptor: font.fontDescriptor, size: newFontSize)
    }
    
    private func max(_ first:CGFloat, _ second:CGFloat) -> CGFloat {
        return first > second ? first : second
    }
}
    

The idea here is we need to test the label's font on a preferred device first to see it look ok. In my case, I chose iPhone 6, and the font size 21 looks perfect on it.

Then, I noted down the iPhone 6's screen size in points so that i can calculate the screen size difference (in points) between the iPhone 6 and the current device the app is running on. The font size is recalculated based on the screen difference.


The View's Margins in iOS

The top, leading, bottom, and trailing margins of a view define the spaces between the view's edges and its contents.



Setting layout margins in Interface Builder


The margins can be set in Interface Builder by updating the Layout Margins setting (in Size Inspector) to either Language Directional or Fixed then specify the values for Leading, Top, Bottom, and Trailing edges.


Then, we can create a constraint for the subview's edges relative to superview's margins as following:


Note that if we don't check the "Constraint to margins", it means we constraint the subview's edges relatively to its superview's edges, not the layout margins.



Setting layout margins programmatically


Open the ViewController.swift file and update the viewDidLoad() method as following:
    
import UIKit

class SecondViewController: UIViewController {
    
    @IBOutlet weak var view1: UIView!
    
    override func viewDidLoad() {
        let layoutMarginGuide = self.view.layoutMarginsGuide
        view1.leadingAnchor.constraint(equalTo: layoutMarginGuide.leadingAnchor).isActive = true
        view1.topAnchor.constraint(equalTo: layoutMarginGuide.topAnchor).isActive = true
        view1.trailingAnchor.constraint(equalTo: layoutMarginGuide.trailingAnchor).isActive = true
        view1.bottomAnchor.constraint(equalTo: layoutMarginGuide.bottomAnchor).isActive = true
        view1.translatesAutoresizingMaskIntoConstraints = false
    }
}
    



Auto layout and Constraints in iOS

Layout is the way in which the UI components (UIView, UIButton, UILabel) are arranged together. Auto layout means the UI components dynamically change their sizes and positions according to external or internal changes such as the screen rotates. We have to define constraints for those UI components either in Interface Builder or programmatically so that they know how to change in different situations. For example, we can set a constraint for a button to always stay in the center of the screen both horizontally and vertically.

We must set enough constraints for each UI control added or we will get unexpected result like the control shows up in portrait mode but doesn't in landscape mode. 

How to know what constraints should be set? It's based on circumstances. Logically, the system must know the UI control's position and size so the constraint should tell enough information about the size and position. However, if we specify constraints for the top, leading, bottom, and trailing edges of an image view equal to its superview's, we don't have to specify its size because that's enough for the system to know its size. its size will always be the same as its superview's size.



Traditionally, without auto layout, the developers must calculate the size and position of a view's frame and its subview's frame. Then, when the screen's size changes, they have to recalculate the frame's sizes and positions for the view and its subviews.


Adding Constraints in Interface Builder


1). Drag a UIView object from the Object Library and drop it in the View Controller's view.


2). Control-drag the subview to the ViewController's view and select "Leading Space to Safe Area" from the popup.



Then, select the subview and open the Size Inspector and the leading constraint would appear there.


In my case, the leading space is 62 because I dropped the subview at that position. We can change it though by selecting the constraint in the Size Inspector and click Edit then update the Constant to 0 so that the subview's leading edge equal to the superview's leading edge.

3). Repeat Step 2 to set 3 more constraints for the subview by selecting "Top Space to Safe Area", "Trailing Space to Safe Area", and "Bottom Space to Safe Area" when the pop up appears. The image below shows the result of this step.



It's finished for our purpose here. The subview will dynamically change its size to maintain the defined spaces between its edges and the superview's.


Adding Constraints Programmatically


1). Control-drag the subview to the SecondViewController.swift in my case, to create an outlet so that we can access the subview from code


2). The constraints should be set in ViewController's viewDidLoaded() method as following:
    
import UIKit

class SecondViewController: UIViewController {
    
    @IBOutlet weak var view1: UIView!
    
    override func viewDidLoad() {
        view1.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
        view1.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
        view1.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
        view1.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
        view1.translatesAutoresizingMaskIntoConstraints = false
    }
}
    

The leadingAnchor, topAnchor, trailingAnchor, and bottomAnchor properties represent the leading, top, trailing, and bottom edges of the view's frame, respectively.

Note that if we have the constraints set both in Interface Builder and programmatically, the ones in Interface Builder take effect. If we want to programmatically modify the constraints defined in Interface Builder, we have to create the outlets connected the constraints from Interface Builder and modify them in viewDidLoad() method.

Layout Margins
We can define the constraints relative to the superview's margin instead. See here for more details.



Introduction to UIViewController

Normally, every app has at least one view controller (an instance of UIViewController class). Each view controller has one root view (an instance of UIView class). Commonly, one view controller manages one page.

View Controller's responsibilities:
- Updating the contents of the views
- Responding to user interactions with the views
- Resizing the views and managing the overall interface
- Coordinating with other objects, including other view controllers, in your app.

When creating Single View Application project, Xcode adds a view controller by default. The page or view that the view controller manages can be designed in the Storyboard (Main.storyboard file). For any additional setups for or actions to do responding to the user's interactions with the views in the page can be programmatically done in the corresponding ViewController.swift file. The ViewController class is connected to the view controller in the Storyboard through Class setting in Identity Inspector.



View Controller's Lifecycle


There are a bunch of methods, for example viewWillAppear(), are called according to its state. Usually, the property initializations are done in viewDidLoad() method. Note that any operations related to the view's or the subview's bounds property should be done in viewDidAppear() method because that's when the value of the bounds property is finalized. However, if there is a delay in the view rending and it does not look good to the user, the operations can be done in viewWillAppear() method instead but the layoutIfNeeded() method must be called first so that the system will layout any pending views and then you get the correct value of the bounds property.

Embedding A View Controller's Root View in Another View Controller's View


This post shows how to do it.