Skip to content

Commit

Permalink
Generalized animations (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski authored Sep 12, 2024
1 parent e91fd00 commit ea29dce
Show file tree
Hide file tree
Showing 17 changed files with 623 additions and 234 deletions.
85 changes: 69 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="./racehorse.png" alt="Racehorse" width="500"/>
<img src="./assets/racehorse.png" alt="Racehorse" width="500"/>
</p>

The bootstrapper for WebView-based Android apps.
Expand Down Expand Up @@ -1340,8 +1340,8 @@ EventBus.getDefault().register(HttpsPlugin())

# Keyboard plugin

[`KeyboardManager`](https://smikhalevski.github.io/racehorse/interfaces/racehorse.KeyboardManager.html) enables
software keyboard status monitoring.
[`KeyboardManager`](https://smikhalevski.github.io/racehorse/interfaces/racehorse.KeyboardManager.html) toggles
the software keyboard and notifies about keyboard animation.

1. Initialize the plugin in your Android app:

Expand All @@ -1351,39 +1351,92 @@ import org.racehorse.KeyboardPlugin
EventBus.getDefault().register(KeyboardPlugin(activity).apply { enable() })
```

2. Synchronously read the keyboard height or subscribe to its changes when keyboard is toggled:
2. Synchronously read the keyboard height, show or hide the keyboard:

```ts
import { keyboardManager } from 'racehorse';

keyboardManager.showKeyboard();
// ⮕ true

keyboardManager.getKeyboardHeight();
// ⮕ 630
```

Subscribe to the keyboard manager to receive notifications when the keyboard animation starts:

```ts
keyboardManager.subscribe(animation => {
// Handle the started animation here.
});
```

If you are using React, use
[`useKeyboardAnimation`](https://smikhalevski.github.io/racehorse/functions/_racehorse_react.useKeyboardAnimation.html)
hook to subscribe to the keyboard animation from a component:

```tsx
import { useKeyboardAnimation } from '@racehorse/react';

keyboardManager.subscribe('toggled', keyboardHeight => {
// React to keyboard height changes
useKeyboardAnimation((animation, signal) => {
// Signal is aborted if animation is cancelled.
});
```

Subscribe to the software keyboard animation start:
Use [`runAnimation`](https://smikhalevski.github.io/racehorse/interfaces/racehorse.runAnimation.html) to run
the animation. For example, if your
[app is rendered edge-to-edge](https://developer.android.com/develop/ui/views/layout/edge-to-edge), you can animate
the bottom padding to compensate the height of the keyboard.

```ts
keyboardManager.subscribe('beforeToggled', animation => {
// Start the animation
import { useKeyboardAnimation, runAnimation } from '@racehorse/react';

useKeyboardAnimation((animation, signal) => {
// Run the animation in sync with the native keyboard animation.
runAnimation(
animation,
{
onProgress(animation, fraction, percent) {
const keyboardHeight = animation.startValue + (animation.endValue - animation.startValue) * fraction;

document.body.style.paddingBottom = keyboardHeight + 'px';
}
},
signal
);
});
```

If you are using React, then refer to
[`useKeyboardAnimationHandler`](https://smikhalevski.github.io/racehorse/functions/_racehorse_react.useKeyboardAnimationHandler.html)
hook that allows to seamlessly replicate keyboard animation inside the WebView:
You may also want to scroll the window to prevent the focused element from bing obscured by the keyboard.
Use [`scrollToElement`](https://smikhalevski.github.io/racehorse/interfaces/racehorse.scrollToElement.html) to animate
scrolling in sync with keyboard animation:

```tsx
import { useKeyboardAnimationHandler } from '@racehorse/react';
```ts
import { useKeyboardAnimation, scrollToElement } from '@racehorse/react';

useKeyboardAnimation((animation, signal) => {

useKeyboardAnimationHandler((animation, keyboardHeight) => {
document.body.style.paddingBottom = keyboardHeight + 'px';
// Ensure there's an active element to scroll to.
if (document.activeElement === null) {
return;
}

scrollToElement(document.activeElement, {
// Scroll animation would have the same duration and easing as the keyboard animation.
animation,
paddingBottom: animation.endValue,
signal,
});
});
```

Check out [the example app](./web/example/src/App.tsx#L44) that has the real-world keyboard animation handling.

<br/>
<p align="center">
<img src="./assets/keyboard-animation.gif" alt="Keyboard animation" width="300"/>
</p>

# Network plugin

[`NetworkManager`](https://smikhalevski.github.io/racehorse/interfaces/racehorse.NetworkManager.html) enables network
Expand Down
50 changes: 24 additions & 26 deletions android/racehorse/src/main/java/org/racehorse/KeyboardPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class GetKeyboardStatusEvent : RequestEvent() {
* an equidistant abscissa values (x).
* @param startTime A timestamp when an animation has started.
*/
class Animation(
class TweenAnimation(
val startValue: Float,
val endValue: Float,
val duration: Int,
Expand All @@ -55,22 +55,21 @@ class GetKeyboardHeightEvent : RequestEvent() {
/**
* Shows the software keyboard.
*/
class ShowKeyboardEvent : WebEvent
class ShowKeyboardEvent : RequestEvent() {
class ResultEvent(val isSuccessful: Boolean) : ResponseEvent()
}

/**
* Hides the software keyboard.
*/
class HideKeyboardEvent : WebEvent
class HideKeyboardEvent : RequestEvent() {
class ResultEvent(val isSuccessful: Boolean) : ResponseEvent()
}

/**
* Notifies the web app that the keyboard animation has started.
*/
class BeforeKeyboardToggledEvent(val animation: Animation) : NoticeEvent

/**
* Notifies the web app that the keyboard is fully shown or hidden. Event is published after an animation has finished.
*/
class KeyboardToggledEvent(val height: Float) : NoticeEvent
class KeyboardAnimationStartedEvent(val animation: TweenAnimation) : NoticeEvent

/**
* Monitors keyboard visibility.
Expand All @@ -89,7 +88,7 @@ open class KeyboardPlugin(private val activity: Activity, private val eventBus:
KeyboardInsetsAnimationCallback(activity, eventBus)
)
} else {
// Support legacy Android versions
// Legacy Android support
activity.window.decorView.viewTreeObserver.addOnGlobalLayoutListener(
KeyboardGlobalLayoutListener(activity, eventBus)
)
Expand All @@ -109,14 +108,20 @@ open class KeyboardPlugin(private val activity: Activity, private val eventBus:

@Subscribe
open fun onShowKeyboard(event: ShowKeyboardEvent) {
inputMethodManager.showSoftInput(activity.currentFocus, 0)
event.respond(
ShowKeyboardEvent.ResultEvent(inputMethodManager.showSoftInput(activity.currentFocus, 0))
)
}

@Subscribe
open fun onHideKeyboard(event: HideKeyboardEvent) {
inputMethodManager.hideSoftInputFromWindow(
activity.currentFocus?.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
event.respond(
HideKeyboardEvent.ResultEvent(
inputMethodManager.hideSoftInputFromWindow(
activity.currentFocus?.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
)
)
}
}
Expand Down Expand Up @@ -148,26 +153,20 @@ private class KeyboardInsetsAnimationCallback(private val activity: Activity, pr
): WindowInsetsAnimationCompat.BoundsCompat {
if (animation.typeMask and Type.ime() != 0) {
endHeight = getKeyboardHeight(activity)
eventBus.post(BeforeKeyboardToggledEvent(getKeyboardAnimation(animation)))
eventBus.post(KeyboardAnimationStartedEvent(getKeyboardAnimation(animation)))
eventBus.post(KeyboardStatusChangedEvent(KeyboardStatus(endHeight)))
}
return bounds
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (animation.typeMask and Type.ime() != 0) {
eventBus.post(KeyboardToggledEvent(getKeyboardHeight(activity)))
eventBus.post(KeyboardStatusChangedEvent(KeyboardStatus(getKeyboardHeight(activity))))
}
}

override fun onProgress(
windowInsets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>
): WindowInsetsCompat {
return windowInsets
}

private fun getKeyboardAnimation(animation: WindowInsetsAnimationCompat): Animation {
private fun getKeyboardAnimation(animation: WindowInsetsAnimationCompat): TweenAnimation {
val durationScale =
Settings.Global.getFloat(activity.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)

Expand All @@ -185,7 +184,7 @@ private class KeyboardInsetsAnimationCallback(private val activity: Activity, pr
}
?: floatArrayOf(0f, 1f)

return Animation(startHeight, endHeight, duration, easing)
return TweenAnimation(startHeight, endHeight, duration, easing)
}
}

Expand All @@ -202,8 +201,7 @@ private class KeyboardGlobalLayoutListener(private val activity: Activity, priva
return
}

eventBus.post(BeforeKeyboardToggledEvent(Animation(prevHeight, height, 0, floatArrayOf(0f, 1f))))
eventBus.post(KeyboardToggledEvent(height))
eventBus.post(KeyboardAnimationStartedEvent(TweenAnimation(prevHeight, height, 0, floatArrayOf(0f, 1f))))
eventBus.post(KeyboardStatusChangedEvent(KeyboardStatus(getKeyboardHeight(activity))))

prevHeight = height
Expand Down
Binary file added assets/keyboard-animation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
38 changes: 31 additions & 7 deletions web/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,46 @@ import { AssetLoaderExample } from './examples/AssetLoaderExample';
import { ContactsExample } from './examples/ContactsExample';
import { FsExample } from './examples/FsExample';
import { EvergreenExample } from './examples/EvergreenExample';
import { useKeyboardAnimationHandler, useWindowInsets } from '@racehorse/react';
import { useKeyboardAnimation, useKeyboardManager, useWindowInsets } from '@racehorse/react';
import { runAnimation, scrollToElement } from 'racehorse';

export function App() {
const keyboardManager = useKeyboardManager();
const windowInsets = useWindowInsets();

useLayoutEffect(() => {
document.body.style.padding =
windowInsets.top + 'px ' + windowInsets.right + 'px ' + windowInsets.bottom + 'px ' + windowInsets.left + 'px';
windowInsets.top +
'px ' +
windowInsets.right +
'px ' +
Math.max(keyboardManager.getKeyboardHeight(), windowInsets.bottom) +
'px ' +
windowInsets.left +
'px';
}, [windowInsets]);

useKeyboardAnimationHandler((_animation, height, _percent) => {
document.body.style.paddingBottom = Math.max(height, windowInsets.bottom) + 'px';

if (document.activeElement !== null && document.activeElement !== document.body) {
document.activeElement.scrollIntoView({ block: 'center' });
useKeyboardAnimation((animation, signal) => {
// Scroll to the active element when keyboard is shown
if (animation.endValue !== 0 && document.activeElement !== null && document.activeElement !== document.body) {
scrollToElement(document.activeElement, {
// Scroll animation has the same duration and easing as the keyboard animation
animation,
paddingBottom: animation.endValue,
signal,
});
}

runAnimation(
animation,
(animation, fraction) => {
const keyboardHeight = animation.startValue + (animation.endValue - animation.startValue) * fraction;

// Additional padding to compensate the keyboard height
document.body.style.paddingBottom = Math.max(windowInsets.bottom, keyboardHeight) + 'px';
},
signal
);
});

return (
Expand Down
2 changes: 1 addition & 1 deletion web/example/src/examples/KeyboardExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { keyboardManager } from 'racehorse';
export function KeyboardExample() {
const [keyboardHeight, setKeyboardHeight] = useState(keyboardManager.getKeyboardHeight);

useEffect(() => keyboardManager.subscribe('toggled', setKeyboardHeight), []);
useEffect(() => keyboardManager.subscribe(animation => setKeyboardHeight(animation.endValue)), []);

return (
<>
Expand Down
Loading

0 comments on commit ea29dce

Please sign in to comment.