Skip to content

Commit

Permalink
feat: timing animation (#515)
Browse files Browse the repository at this point in the history
## 📜 Description

A follow up PR for
#412
- follow keyboard frame-in-frame if keyboard animation is **not** spring
animation.

## 💡 Motivation and Context

On iOS keyboard animation can be not only spring animation. It can be
also `CABasicAnimation` (when keyboard goes up after interactive
dismissal, for example).

Before we were relying on `CADisplayLink` and coordinates pooling, but
such approach doesn't give a good precision. Even after implementing
custom `SpringAnimation` we still relied on this approach (we used this
approach as a fallback if the animation is not `CASpringAnimation`).

In this PR I implemented custom `TimingAnimation` class and if the
animation is `CABasicAnimation` then we also can assure frame precision.
All these math calculations were based on this [Desmos
playground](https://www.desmos.com/calculator/eynenh1aga?lang=en) - I
used concrete params of a particular animation config and concrete
values produced by iOS. And then I wrote a code 🙃

> _As you can see some of points are not perfectly aligned to the curve
- I think a similar behavior I also observed with `SpringAnimation` and
later on I'll improve that as well_

For finding `t` for particular `x` (or `y`) I used
[Newton-Raphson](https://en.wikipedia.org/wiki/Newton%27s_method)
method. It has better convergence than a binary search and typically I
can find a value with given precision within 6 iterations.

Last but not least - the animation (`CABaicAnimation`) is not available
straight in `keyboardWillShow` callback (it's `nil` there) and it's
available only in `CADisplayLink` callback (so I added an attempt of
animation initialization there as well).

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### iOS

- added `TimingAnimation` class;
- use `TimingAnimation` in `KeyboardMovementObserver`.

## 🤔 How Has This Been Tested?

Tested manually on:
- iPhone 6s (iOS 15.8)
- iPhone 11 (iOS 17.4);
- iPhone 14 Pro (iOS 17.4);
- iPhone 15 Pro (iOS 17.5, simulator);

## 📸 Screenshots (if appropriate):

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/1c1bba40-c76e-468f-87aa-7e9105715ab7">|<video
src="https://github.com/user-attachments/assets/55de0c4d-9d0a-4775-a4dd-4983acc30283">|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko committed Jul 22, 2024
1 parent 85ca03c commit 7c3f4ae
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 7 deletions.
6 changes: 5 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@
"autoplayed",
"bridgeless",
"scrollview",
"AOSP"
"AOSP",
"bezier",
"bézier",
"Raphson",
"desmos"
],
"ignorePaths": [
"node_modules",
Expand Down
2 changes: 1 addition & 1 deletion ios/core/KeyboardAnimation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ protocol KeyboardAnimationProtocol {
}

public class KeyboardAnimation: KeyboardAnimationProtocol {
private weak var animation: CAMediaTiming?
weak var animation: CAMediaTiming?

// constructor variables
let fromValue: Double
Expand Down
108 changes: 108 additions & 0 deletions ios/core/TimingAnimation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
// TimingAnimation.swift
// react-native-keyboard-controller
//
// Created by Kiryl Ziusko on 02/05/2024.
//

import Foundation
import QuartzCore

// swiftlint:disable identifier_name
/**
* A class that handles timing animations using Bézier curves.
*
* This class calculates the progress of animations based on Bézier curve control points.
* For more details on the Bézier curves, see [Desmos Graph](https://www.desmos.com/calculator/eynenh1aga?lang=en).
*/
public class TimingAnimation: KeyboardAnimation {
private let p1: CGPoint
private let p2: CGPoint

init(animation: CABasicAnimation, fromValue: Double, toValue: Double) {
let timingFunction = animation.timingFunction
var controlPoints: [Float] = [0, 0, 0, 0]
timingFunction?.getControlPoint(at: 1, values: &controlPoints[0])
timingFunction?.getControlPoint(at: 2, values: &controlPoints[2])
let p1 = CGPoint(x: CGFloat(controlPoints[0]), y: CGFloat(controlPoints[1]))
let p2 = CGPoint(x: CGFloat(controlPoints[2]), y: CGFloat(controlPoints[3]))

self.p1 = p1
self.p2 = p2

super.init(fromValue: fromValue, toValue: toValue, animation: animation)
}

func bezier(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
let u = 1 - t
let tt = t * t
let uu = u * u

// Calculate the terms for the Bézier curve
// term0 is evaluated as `0`, because P0(0, 0)
// let term0 = uu * u * valueForPoint(p0) // P0
let term1 = 3 * uu * t * valueForPoint(p1) // P1
let term2 = 3 * u * tt * valueForPoint(p2) // P2
let term3 = tt * t // * valueForPoint(p3), because P3(1, 1)

// Sum all terms to get the Bézier value
return term1 + term2 + term3
}

func bezierY(t: CGFloat) -> CGFloat {
return bezier(t: t) { $0.y }
}

func bezierX(t: CGFloat) -> CGFloat {
return bezier(t: t) { $0.x }
}

// public functions
override func valueAt(time: Double) -> Double {
let x = time * Double(speed)
let frames = (animation?.duration ?? 0.0) * Double(speed)
let fraction = min(x / frames, 1)
let t = findTForX(xTarget: fraction)

let progress = bezierY(t: t)

return fromValue + (toValue - fromValue) * CGFloat(progress)
}

func findTForX(xTarget: CGFloat, epsilon: CGFloat = 0.0001, maxIterations: Int = 100) -> CGFloat {
var t: CGFloat = 0.5 // Start with an initial guess of t = 0.5
for _ in 0 ..< maxIterations {
let currentX = bezierX(t: t) // Compute the x-coordinate at t
let derivativeX = bezierXDerivative(t: t) // Compute the derivative at t
let xError = currentX - xTarget
if abs(xError) < epsilon {
return t
}
t -= xError / derivativeX // Newton-Raphson step
t = max(min(t, 1), 0) // Ensure t stays within bounds
}
return t // Return the approximation of t
}

func bezierDerivative(t: CGFloat, valueForPoint: (CGPoint) -> CGFloat) -> CGFloat {
let u = 1 - t
let uu = u * u
let tt = t * t
let tu = t * u

// term0 is evaluated as `0`, because P0(0, 0)
// let term0 = -3 * uu * valueForPoint(p0)
let term1 = (3 * uu - 6 * tu) * valueForPoint(p1)
let term2 = (6 * tu - 3 * tt) * valueForPoint(p2)
let term3 = 3 * tt // * valueForPoint(p3), because P3(1, 1)

// Sum all terms to get the derivative
return term1 + term2 + term3
}

func bezierXDerivative(t: CGFloat) -> CGFloat {
return bezierDerivative(t: t) { $0.x }
}
}

// swiftlint:enable identifier_name
17 changes: 12 additions & 5 deletions ios/observers/KeyboardMovementObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ public class KeyboardMovementObserver: NSObject {
return
}

prevKeyboardPosition = position

onEvent(
"onKeyboardMoveInteractive",
position as NSNumber,
Expand Down Expand Up @@ -269,11 +271,13 @@ public class KeyboardMovementObserver: NSObject {
}

func initializeAnimation(fromValue: Double, toValue: Double) {
let positionAnimation = keyboardView?.layer.presentation()?.animation(forKey: "position") as? CASpringAnimation
guard let keyboardAnimation = positionAnimation else {
return
guard let positionAnimation = keyboardView?.layer.presentation()?.animation(forKey: "position") else { return }

if let springAnimation = positionAnimation as? CASpringAnimation {
animation = SpringAnimation(animation: springAnimation, fromValue: fromValue, toValue: toValue)
} else if let basicAnimation = positionAnimation as? CABasicAnimation {
animation = TimingAnimation(animation: basicAnimation, fromValue: fromValue, toValue: toValue)
}
animation = SpringAnimation(animation: keyboardAnimation, fromValue: fromValue, toValue: toValue)
}

@objc func updateKeyboardFrame(link: CADisplayLink) {
Expand All @@ -289,6 +293,10 @@ public class KeyboardMovementObserver: NSObject {
return
}

if animation == nil {
initializeAnimation(fromValue: prevKeyboardPosition, toValue: keyboardHeight)
}

prevKeyboardPosition = keyboardPosition

if let animation = animation {
Expand All @@ -307,7 +315,6 @@ public class KeyboardMovementObserver: NSObject {
#endif

let position = CGFloat(animation.valueAt(time: duration))

// handles a case when final frame has final destination (i. e. 0 or 291)
// but CASpringAnimation can never get to this final destination
let race: (CGFloat, CGFloat) -> CGFloat = animation.isIncreasing ? max : min
Expand Down

0 comments on commit 7c3f4ae

Please sign in to comment.