Skip to content

Commit

Permalink
fix: keyboard animation if cross-fade transitions enabled on iOS (#570)
Browse files Browse the repository at this point in the history
## 📜 Description

Fixed keyboard animation when cross-fade transitions enabled on iOS.

## 💡 Motivation and Context

That was a pretty interesting question on how to handle that in the
library. Because from the one side the height of the keyboard becomes
"static" (i. e. it's shown instantly, but we animate only opacity), so
properties such as `height` should be a static and only `progress`
should be changed. From the other side it would be reasonable to add
`opacity` property in this case. But I think it would significantly
increase the complexity of the library and before implementing a fix I
decided to have a look how default iOS apps handles that (such as
iMessages and Calendar):

|iMessage|Calendar|
|----------|---------|
|<video
src="https://github.com/user-attachments/assets/e745c594-2bb0-4632-88e7-054fbeeffa87">|<video
src="https://github.com/user-attachments/assets/78262914-0478-471b-a6f6-4f6d9db341b3">|

So as you can see we in both cases we still have smooth vertical
transitions so it made me thinking that we still can animate `height`
and `progress` as before. If we want to apply reduced animation, then we
still can do that based on `progress`:

```tsx
import {AccessibilityInfo} from 'react-native';

const useCrossFadeKeyboardTransition = () => {
  const [areCrossFadeTransitionsEnabled, setCrossFadeTransitionsEnabled] = useState(false);
  const keyboardHeight = useSharedValue(0);
  const transition = useSharedValue(0);
  const opacity = useSharedValue(1);

  useEffect(() => {
    AccessibilityInfo.prefersCrossFadeTransitions()
      .then(enabled => setCrossFadeTransitionsEnabled(enabled));
  }, []);

  useKeyboardHandler({
    onStart: (e) => {
      "worklet";
      keyboardHeight.value = e.height;
    },
    onMove: (e) => {
      "worklet";
      if (areCrossFadeTransitionsEnabled) {
        opacity.value = progress.value;
      } else {
        transition.value = height.value;
      }
    },
  }, [areCrossFadeTransitionsEnabled]);
  
  const style = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ translateY: transition.value }]
  }), []);

  // ...
};
```

The other thing was the algorithm for detection cross fade transitions
and usage specific code for that. The first perspective idea was the
usage of this extension:

```swift
import Foundation

public extension UIAccessibility {
  static var areCrossFadeTransitionsEnabled: Bool {
    if #available(iOS 14.0, *) {
      return UIAccessibility.prefersCrossFadeTransitions
    } else {
      return false
    }
  }
}
```

While it may seem working this code has several disadvantages:
- the `prefersCrossFadeTransitions` property is available only from iOS
`14+` (but the setting was introduced in iOS 13);
- even if `prefersCrossFadeTransitions` enabled keyboard can be animated
vertically 😲 (after interactive gesture)
- even if `prefersCrossFadeTransitions` enabled keyboard can be animated
vertically on older iOS versions (iOS 15 for example)

So I started to look for other approaches. The thing is that even with
`.prefersCrossFadeTransitions` we would just read other property of the
layer (`opacity` vs `position`), so I decided to try this approach
without `.prefersCrossFadeTransitions` (we check for different animation
keys - initially we check `position`, if animation is not found then we
check `opacity`, if not found then we don't initialize animation).

Closes
#569

## 📢 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

- rename `framePositionInWindow` -> `frameTransitionInWindow` (to make
it clear that we read isomorphic transition instead of particular
position/opacity/etc.);
- read `opacity` animation from layer in `framePositionInWindow`
extension and if animation is found, then interpolate `transition` based
on the value of the opacity;
- try initialize animation from 2 layers (position, opacity) instead of
1 layer (position);

## 🤔 How Has This Been Tested?

Tested on:
- iPhone 15 Pro (iOS 17.5, simulator);
- iPhone 13 Pro (iOS 15.5, simulator);
- iPhone 11 (iOS 17.5, real device);
- iPhone 6s (iOS 15.8, real device);

## 📸 Screenshots (if appropriate):

### Interactive keyboard


https://github.com/user-attachments/assets/dd9592a8-c616-4af8-8710-9409ac0b3c06

### Plain transitions (keyboard animation with keyboard resize)


https://github.com/user-attachments/assets/adcca246-dd82-4c14-976e-f95689c51f1a

### KeyboardAvoidingView


https://github.com/user-attachments/assets/5c1f1b72-5e94-478e-8a5c-a314104da7ea

### KeyboardAwareScrollView


https://github.com/user-attachments/assets/946a71dd-6b59-4124-9b9e-a0fad1618e5f

### Modal


https://github.com/user-attachments/assets/ff362b38-f0f4-44a9-8dac-866a5233006d


## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko authored Aug 29, 2024
1 parent d6143db commit 20c2d10
Show file tree
Hide file tree
Showing 2 changed files with 21 additions and 10 deletions.
12 changes: 10 additions & 2 deletions ios/extensions/UIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ public extension UIView {
}

public extension Optional where Wrapped == UIView {
var framePositionInWindow: (Double, Double) {
var frameTransitionInWindow: (Double, Double) {
let areCrossFadeTransitionsEnabled = (self?.layer.presentation()?.animationKeys() ?? []).contains("opacity")
let frameY = self?.layer.presentation()?.frame.origin.y ?? 0
let windowH = self?.window?.bounds.size.height ?? 0
let position = windowH - frameY
var position = windowH - frameY

// when cross fade transitions enabled, then keyboard changes
// its `opacity` instead of `translateY`, so we handle it here
if areCrossFadeTransitionsEnabled {
let opacity = self?.layer.presentation()?.opacity ?? 0
position = CGFloat(opacity) * position
}

return (position, frameY)
}
Expand Down
19 changes: 11 additions & 8 deletions ios/observers/KeyboardMovementObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public class KeyboardMovementObserver: NSObject {
let timestamp = Date.currentTimeStamp
let (duration, frame) = metaDataFromNotification(notification)
if let keyboardFrame = frame {
let (position, _) = keyboardView.framePositionInWindow
let (position, _) = keyboardView.frameTransitionInWindow
let keyboardHeight = keyboardFrame.cgRectValue.size.height
tag = UIResponder.current.reactViewTag
self.keyboardHeight = keyboardHeight
Expand Down Expand Up @@ -249,12 +249,15 @@ public class KeyboardMovementObserver: NSObject {
}

func initializeAnimation(fromValue: Double, toValue: Double) {
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)
for key in ["position", "opacity"] {
if let keyboardAnimation = keyboardView?.layer.presentation()?.animation(forKey: key) {
if let springAnimation = keyboardAnimation as? CASpringAnimation {
animation = SpringAnimation(animation: springAnimation, fromValue: fromValue, toValue: toValue)
} else if let basicAnimation = keyboardAnimation as? CABasicAnimation {
animation = TimingAnimation(animation: basicAnimation, fromValue: fromValue, toValue: toValue)
}
return
}
}
}

Expand All @@ -263,7 +266,7 @@ public class KeyboardMovementObserver: NSObject {
return
}

let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.framePositionInWindow
let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.frameTransitionInWindow
var keyboardPosition = visibleKeyboardHeight

if keyboardPosition == prevKeyboardPosition || keyboardFrameY == 0 {
Expand Down

0 comments on commit 20c2d10

Please sign in to comment.