Quantcast
Channel: FlexMonkey
Viewing all articles
Browse latest Browse all 257

Animated Layout for Swift with Shinpuru

$
0
0

I've spent some time extending my Shinpuru Layout group components to support two new methods: addChild() and removeChild(). These methods animate the addition or removal of components which is illustrated in the animated gif above.

Before delving into how I've implemented this code, let's look at an implementation. I've added a new demonstration to my Shinpuru Layout project called AddAndRemove. It consists of a main vertical group that contains a second vertical group, rowsGroup, containing dynamically created rows and a toolbar with a fixed height:



When the user clicks "add" in the toolbar, the code creates a new instance of HorizontalRow which is added to rowsGroup and when they click "add" in a row, a new yellow UIView is added to that row. 

The code to create this user interface is very simply done us Shinpuru. In the AddAndRemove view controller, I define the three main groups: 

    let mainGroup = SLVGroup()
    let rowsGroup = SLVGroup()

    let toolbarGroup = SLHGroup()

...and in viewDidLoad(), I create the view hierarchy and add it to the view (I've edited out some code):

    overridefunc viewDidLoad()
    {
        view.backgroundColor = UIColor.blackColor()
        view.addSubview(mainGroup)
        
        mainGroup.margin = 10
        rowsGroup.margin = 10
        toolbarGroup.margin = 10
        
        toolbarGroup.explicitSize = 40
        
        mainGroup.addChild(toolbarGroup, atIndex: 0)
        mainGroup.addChild(rowsGroup, atIndex: 0)

    }

I've also added the add and remove buttons to the toolbar which use the new addChild() and removeChild() methods:

    func addClickHandler()
    {
        rowsGroup.addChild(HorizontalRow(), atIndex: 0)
    }
    
    func removeClickHandler()
    {
        rowsGroup.removeChild(atIndex: 0)

    }

...and the code inside the horizontal rows is pretty much identical. 

The code to do the adding and removing is in the SLGroup class and identical for both horizontal and vertical layouts. Let's look at adding a new child first.

Because I'm animating the custom property explicitSize of my Shinpuru layout components, I couldn't use the standard UIView animate with duration methods and I couldn't get Core Animation to work nicely, so I've ended up creating my own animation code based upon NSTimer (the fade part of the animation still uses UIView.animateWithDuration).

With that in mind, the first thing that addChild() does is check if there's an animation running and if there is, queues up that add request to happen once the current animation is complete:

    func addChild(child: UIView, atIndex: Int)
    {
        ifanimationRunning
        {
            ifanimationQueueItems.filter({ $0.child == child }).count == 0
            {
                animationQueueItems.append( SLGroupAnimationQueueItem(.Add, atIndex, child) )
            }
            return
        }

        [...]

...if we get past that, it's time to start adding the new component. I have an SLSpacer component named transientChildSpacer which is inserted at the desired new child index and which will have its explicit size changed over time to create a gap for the new child. I also set the alpha of the actual new child component to zero so that I can fade it in:

        [...]
        animationRunning = true
        
        let targetIndex = min(children.count, atIndex)
        
        transientChildSpacer.explicitSize = 0
        children.insert(transientChildSpacer, atIndex: targetIndex)
        newChildIndex = targetIndex
        newChild = child
        newChild?.alpha = 0

        [...]

I then create a copy of the current children, add in the required child component and use the existing methods to figure out the actual size of the new child (newChildExplicitSize) and the delta with each animation step (sizeStep):

        [...]
        let availableSize = selfisSLHGroup ? frame.width : frame.height

        var candidateChildren = children
        candidateChildren.insert(child, atIndex: targetIndex)
        let layoutMetrics = SLGroup.calculateLayoutMetrics(candidateChildren)
        let childMetrics = SLGroup.calculateChildMetrics(children: candidateChildren, childPercentageSizes: layoutMetrics.childPercentageSizes, availableSize: availableSize, totalExplicitSize: layoutMetrics.totalExplicitSize)

        newChildExplicitSize = childMetrics[targetIndex].size
        sizeStep = newChildExplicitSize! / animationSteps

        [...]

Finally, I invoke addStep() which will execute with each animation frame:

        [...]
        addStep()

    }

The addStep() method checks to see if newChild is nil and whether the explicit size of the transient child spacer is less than the newChildExplicitSize. If so, it simply adds the sizeStep to the explicitSize of the transientChildSpacer:

    func addStep()
    {
        ifnewChild != nil&& transientChildSpacer.explicitSize< newChildExplicitSize
        {
            transientChildSpacer.explicitSize! = min(transientChildSpacer.explicitSize! + sizeStep!, newChildExplicitSize!)
        }

        [...]

However, if newChild isn't nil and its size is greater than or equal to the target size, we can swap out the transient child spacer for the actual new child and fade it in:

        [...]
        elseifnewChild != nil
        {
            children[newChildIndex!] = newChild!
            
            UIView.animateWithDuration(fadeDuration, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut, animations: {newChild?.alpha = 1}, completion: nil)
            
            animationRunning = false
            newChild = nil
        }

        [...]

Finally, whichever path has been taken, we invoke setNeedsLayout() which will invoke layoutSubviews() in the next update. At the end of layoutSubviews(), there's a check to see if newChild is nil and if it isn't, invokes addStep() again 1/60th of a second later:

        [...]
        ifnewChild != nil
        {
            NSTimer.scheduledTimerWithTimeInterval(1 / 60, target: self, selector: "addStep", userInfo: nil, repeats: false)
            

        }
        [...]

removeChild() has a similar check for existing animations and if there are any running, adds the remove to a queue. It also exits early if the remove index is out of bounds:

    func removeChild(#atIndex: Int)
    {
        ifanimationRunning
        {
            animationQueueItems.append( SLGroupAnimationQueueItem(.Remove, atIndex, nil) )
            return
        }
        elseif atIndex >= children.count
        {
            return
        }

        [...]

When removing, the fade out happens before the resize. Again, using existing code, I figure out the size of the transient spacer used for the resizing based on the size of the component we're removing:  

        [...]
        animationRunning = true
        
        let availableSize = selfisSLHGroup ? frame.width : frame.height
        
        let layoutMetrics = SLGroup.calculateLayoutMetrics(children)
        let childMetrics = SLGroup.calculateChildMetrics(children: children, childPercentageSizes: layoutMetrics.childPercentageSizes, availableSize: availableSize, totalExplicitSize: layoutMetrics.totalExplicitSize)
        
        transientChildSpacer.explicitSize = childMetrics[atIndex].size
        removeChildIndex = atIndex
        
        sizeStep = transientChildSpacer.explicitSize! / animationSteps

        [...]

Finally, I use UIView.animateWithDuration to fade out the child to be removed and once that fade out is completed, swap out that component for the transient spaced and invoke removeStep():

        [...]
        UIView.animateWithDuration(fadeDuration, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut,
                        animations: {self.children[atIndex].alpha = 0},
                        completion: {(_) inself.children[atIndex] = self.transientChildSpacer; self.removeStep()})

    }

removeStep() isn't dissimilar to addStep(): first it checks whether removeChildIndex isn't nil and whether the size of the transient spacer is greater than zero. If that's the case, it subtracts sizeStep from the spacer's explicitSize:

    func removeStep()
    {
        ifremoveChildIndex != nil&& transientChildSpacer.explicitSize> 0
        {
            transientChildSpacer.explicitSize! = max(transientChildSpacer.explicitSize! - sizeStep!, 0)
        }

        [...]

However, if removeChildIndex isn't nil but the spacer's explicitSize is less than or equal to zero, it can  removed from the children array:

        [...]
        elseifremoveChildIndex != nil
        {
            children.removeAtIndex(removeChildIndex!)
            
            removeChildIndex = nil
            animationRunning = false
        }

        [...]

...and again, both paths call setNeedsLayout():

        [...]
        setNeedsLayout()

    }

At the end of layoutSubviews(), if removeChildIndex isn't nil, removeStep() is invoked 1/60th of a second later:

        [...]
        elseifremoveChildIndex != nil
        {
            NSTimer.scheduledTimerWithTimeInterval(1 / 60, target: self, selector: "removeStep", userInfo: nil, repeats: false)
        }

        [...]

...if, however, both newChild and removeChildIndex are nil, layoutSubviews() invokes getNextAnimation() which will pick the next queued add or remove:

    func getNextAnimation()
    {
        iflet nextAnim = animationQueueItems.first
        {
            nextAnim.type == .Add
                ? addChild(nextAnim.child!, atIndex: nextAnim.index)
                : removeChild(atIndex: nextAnim.index)
            
            animationQueueItems.removeAtIndex(0)
        }

    }

Shinpuru Layout is open source and available at my GitHub repository here.

Viewing all articles
Browse latest Browse all 257

Trending Articles