diff --git a/packages/pluggableWidgets/floating-action-button-native/CHANGELOG.md b/packages/pluggableWidgets/floating-action-button-native/CHANGELOG.md index 72f4dfdcd..090c849d4 100644 --- a/packages/pluggableWidgets/floating-action-button-native/CHANGELOG.md +++ b/packages/pluggableWidgets/floating-action-button-native/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +- We have removed the 'react-native-reanimated' library and used Animated API of react native for animation purpose. +- We fixed an issue of secondary button's caption not displayed if its horizontal position is center. +- We refactored code and migrated to functional component. + ## [4.2.1] - 2026-6-10 ### Changed diff --git a/packages/pluggableWidgets/floating-action-button-native/package.json b/packages/pluggableWidgets/floating-action-button-native/package.json index 5db19bcef..03910be78 100644 --- a/packages/pluggableWidgets/floating-action-button-native/package.json +++ b/packages/pluggableWidgets/floating-action-button-native/package.json @@ -1,7 +1,7 @@ { "name": "floating-action-button-native", "widgetName": "FloatingActionButton", - "version": "4.2.1", + "version": "4.3.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -20,8 +20,7 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "@mendix/piw-utils-internal": "*", - "react-native-reanimated": "^3.0.0" + "@mendix/piw-utils-internal": "*" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*" diff --git a/packages/pluggableWidgets/floating-action-button-native/src/FloatingActionButton.tsx b/packages/pluggableWidgets/floating-action-button-native/src/FloatingActionButton.tsx index 43fc419a4..cc9967599 100644 --- a/packages/pluggableWidgets/floating-action-button-native/src/FloatingActionButton.tsx +++ b/packages/pluggableWidgets/floating-action-button-native/src/FloatingActionButton.tsx @@ -1,16 +1,15 @@ import { flattenStyles } from "@mendix/piw-native-utils-internal"; import { executeAction } from "@mendix/piw-utils-internal"; import { Icon } from "mendix/components/native/Icon"; -import { Component, JSX } from "react"; -import { Pressable, StyleSheet, Text, View, ViewStyle } from "react-native"; -import Animated, { Easing, interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import { JSX, useEffect, useRef, useState } from "react"; +import { Animated, Easing, Pressable, Text, View, ViewStyle } from "react-native"; -import { FloatingActionButtonProps } from "../typings/FloatingActionButtonProps"; -import { defaultFloatingActionButtonStyle, FloatingActionButtonStyle } from "./ui/styles"; - -interface State { - active: boolean; -} +import { + FloatingActionButtonProps, + HorizontalPositionEnum, + VerticalPositionEnum +} from "../typings/FloatingActionButtonProps"; +import { defaultFloatingActionButtonStyle, FloatingActionButtonStyle, styles } from "./ui/styles"; const defaultIconSource = { type: "glyph", iconClass: "glyphicon-plus" } as const; const defaultActiveIconSource = { type: "glyph", iconClass: "glyphicon-remove" } as const; @@ -24,22 +23,84 @@ interface AnimatedMainIconProps { iconActive: FloatingActionButtonProps["iconActive"]; } +interface SecondaryActionItemProps { + active: boolean; + index: number; + direction: "up" | "down"; + horizontalPosition: "left" | "right" | "center"; + name: string; + button: FloatingActionButtonProps["secondaryButtons"][number]; + style: FloatingActionButtonStyle; + mainButtonSize: number; + secondaryButtonSize: number; + onPress: () => void; +} + +function getVerticalOrientation(verticalPosition: VerticalPositionEnum): "up" | "down" { + switch (verticalPosition) { + case "bottom": + return "up"; + case "top": + default: + return "down"; + } +} + +function getPositionStyle( + verticalPosition: VerticalPositionEnum, + horizontalPosition: HorizontalPositionEnum +): ViewStyle { + const positionStyle: ViewStyle = { + position: "absolute", + left: 0, + right: 0, + zIndex: 999 + }; + + switch (verticalPosition) { + case "bottom": + positionStyle.bottom = 0; + break; + case "top": + default: + positionStyle.top = 0; + break; + } + + switch (horizontalPosition) { + case "left": + positionStyle.alignItems = "flex-start"; + break; + case "center": + positionStyle.alignItems = "center"; + break; + case "right": + default: + positionStyle.alignItems = "flex-end"; + break; + } + + return positionStyle; +} + function AnimatedMainIcon(props: AnimatedMainIconProps): JSX.Element { const { active, hasSecondaryButtons, style, icon, iconActive } = props; - const progress = useSharedValue(active && hasSecondaryButtons ? 1 : 0); - progress.value = withTiming(active && hasSecondaryButtons ? 1 : 0, { - duration: 200, - easing: Easing.out(Easing.ease) - }); + const progress = useRef(new Animated.Value(active && hasSecondaryButtons ? 1 : 0)).current; + + useEffect(() => { + Animated.timing(progress, { + toValue: active && hasSecondaryButtons ? 1 : 0, + duration: 200, + easing: Easing.out(Easing.ease), + useNativeDriver: true + }).start(); + }, [active, hasSecondaryButtons, progress]); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { - rotate: `${interpolate(progress.value, [0, 1], [0, -180])}deg` - } - ] - })); + const rotate = progress.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "-180deg"] + }); const iconSource = icon?.value ? icon.value : defaultIconSource; const activeIconSource = iconActive?.value ? iconActive.value : defaultActiveIconSource; @@ -47,26 +108,13 @@ function AnimatedMainIcon(props: AnimatedMainIconProps): JSX.Element { return ( - + ); } -interface SecondaryActionItemProps { - active: boolean; - index: number; - direction: "up" | "down"; - horizontalPosition: "left" | "right" | "center"; - name: string; - button: FloatingActionButtonProps["secondaryButtons"][number]; - style: FloatingActionButtonStyle; - mainButtonSize: number; - secondaryButtonSize: number; - onPress: () => void; -} - function SecondaryActionItem(props: SecondaryActionItemProps): JSX.Element { const { active, @@ -81,32 +129,31 @@ function SecondaryActionItem(props: SecondaryActionItemProps): JSX.Element { onPress } = props; - const progress = useSharedValue(active ? 1 : 0); - progress.value = withTiming(active ? 1 : 0, { - duration: 200, - easing: Easing.out(Easing.ease) - }); + const progress = useRef(new Animated.Value(active ? 1 : 0)).current; - const labelOnRight = horizontalPosition === "left"; + useEffect(() => { + Animated.timing(progress, { + toValue: active ? 1 : 0, + duration: 200, + easing: Easing.out(Easing.ease), + useNativeDriver: true + }).start(); + }, [active, progress]); + + const labelOnRight = horizontalPosition === "left" || horizontalPosition === "center"; const labelOnLeft = horizontalPosition === "right"; - const animatedStyle = useAnimatedStyle(() => { - const centerToCenterDistance = - (mainButtonSize + secondaryButtonSize) / 2 + SECONDARY_GAP + index * (secondaryButtonSize + SECONDARY_GAP); + const centerToCenterDistance = + (mainButtonSize + secondaryButtonSize) / 2 + SECONDARY_GAP + index * (secondaryButtonSize + SECONDARY_GAP); - return { - opacity: progress.value, - transform: [ - { - translateY: interpolate( - progress.value, - [0, 1], - [0, direction === "up" ? -centerToCenterDistance : centerToCenterDistance] - ) - }, - { scale: interpolate(progress.value, [0, 1], [0.8, 1]) } - ] - }; + const translateY = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, direction === "up" ? -centerToCenterDistance : centerToCenterDistance] + }); + + const scale = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0.8, 1] }); const anchorStyle: ViewStyle = { @@ -119,7 +166,14 @@ function SecondaryActionItem(props: SecondaryActionItemProps): JSX.Element { return ( , State> { - readonly state: State = { - active: false - }; - - private readonly onPressHandler = this.onPress.bind(this); - - render(): JSX.Element { - const style = flattenStyles(defaultFloatingActionButtonStyle, this.props.style); - const buttonStyle = { ...style.button }; - delete buttonStyle.rippleColor; +export function FloatingActionButton(props: FloatingActionButtonProps): JSX.Element { + const [active, setActive] = useState(false); + const style = flattenStyles(defaultFloatingActionButtonStyle, props.style); + const { rippleColor, ...buttonStyle } = style.button; - const mainButtonSize = style.button.size ?? 54; - const secondaryButtonSize = style.secondaryButton.size ?? 40; - const horizontalPosition = this.props.horizontalPosition ?? "right"; - const hasSecondaryButtons = !!this.props.secondaryButtons?.length; + const mainButtonSize = style.button.size ?? 54; + const secondaryButtonSize = style.secondaryButton.size ?? 40; + const horizontalPosition = props.horizontalPosition ?? "right"; + const hasSecondaryButtons = !!props.secondaryButtons?.length; - return ( - - {this.renderButtons(style, mainButtonSize, secondaryButtonSize, horizontalPosition)} + const handlePress = (): void => { + if (props.secondaryButtons?.length) { + setActive(prev => !prev); + return; + } - [ - styles.mainButtonBase, - buttonStyle, - { - width: mainButtonSize, - height: mainButtonSize, - borderRadius: mainButtonSize / 2, - opacity: pressed ? 0.2 : 1 - } - ]} - > - - - - ); - } + executeAction(props.onClick); + }; - private renderButtons( + const renderButtons = ( style: FloatingActionButtonStyle, mainButtonSize: number, secondaryButtonSize: number, horizontalPosition: "left" | "right" | "center" - ): JSX.Element[] | undefined { - return this.props.secondaryButtons?.map((button, index) => ( + ): JSX.Element[] | undefined => { + return props.secondaryButtons?.map((button, index) => ( { - this.setState({ active: false }); + setActive(false); executeAction(button.onClick); }} /> )); - } - - private get verticalOrientation(): "up" | "down" { - switch (this.props.verticalPosition) { - case "bottom": - return "up"; - case "top": - return "down"; - default: - return "down"; - } - } - - private getPositionStyle(): ViewStyle { - const positionStyle: ViewStyle = { - position: "absolute", - left: 0, - right: 0, - zIndex: 999 - }; - - switch (this.props.verticalPosition) { - case "bottom": - positionStyle.bottom = 0; - break; - case "top": - default: - positionStyle.top = 0; - break; - } - - switch (this.props.horizontalPosition) { - case "left": - positionStyle.alignItems = "flex-start"; - break; - case "center": - positionStyle.alignItems = "center"; - break; - case "right": - default: - positionStyle.alignItems = "flex-end"; - break; - } - - return positionStyle; - } - - private onPress(): void { - if (this.props.secondaryButtons?.length) { - // eslint-disable-next-line react/no-access-state-in-setstate - this.setState({ active: !this.state.active }); - return; - } + }; - executeAction(this.props.onClick); - } + return ( + + {renderButtons(style, mainButtonSize, secondaryButtonSize, horizontalPosition)} + + [ + styles.mainButtonBase, + buttonStyle, + { + width: mainButtonSize, + height: mainButtonSize, + borderRadius: mainButtonSize / 2, + opacity: pressed ? 0.2 : 1 + } + ]} + > + + + + ); } - -const styles = StyleSheet.create({ - wrapper: { - justifyContent: "flex-end" - }, - mainButtonBase: { - alignItems: "center", - justifyContent: "center" - }, - secondaryAnchor: { - position: "absolute" - }, - secondaryRow: { - width: "100%", - flexDirection: "row", - alignItems: "center" - }, - rowAlignLeft: { - justifyContent: "flex-start" - }, - rowAlignRight: { - justifyContent: "flex-end" - }, - rowAlignCenter: { - justifyContent: "center" - }, - secondaryButtonBase: { - alignItems: "center", - justifyContent: "center", - flexShrink: 0 - }, - captionInlineContainer: { - flexShrink: 0 - }, - captionBeforeButton: { - marginRight: 8, - alignItems: "flex-end" - }, - captionAfterButton: { - marginLeft: 8, - alignItems: "flex-start" - }, - captionText: { - flexShrink: 0 - } -}); diff --git a/packages/pluggableWidgets/floating-action-button-native/src/__tests__/FloatingActionButton.spec.tsx b/packages/pluggableWidgets/floating-action-button-native/src/__tests__/FloatingActionButton.spec.tsx index 05c583df6..e697a2ee7 100644 --- a/packages/pluggableWidgets/floating-action-button-native/src/__tests__/FloatingActionButton.spec.tsx +++ b/packages/pluggableWidgets/floating-action-button-native/src/__tests__/FloatingActionButton.spec.tsx @@ -6,16 +6,6 @@ import { actionValue, dynamicValue } from "@mendix/piw-utils-internal"; import { NativeIcon } from "mendix"; import { Icon } from "mendix/components/native/Icon"; -jest.mock("react-native-reanimated", () => { - const Reanimated = jest.requireActual("react-native-reanimated/lib/module/mock"); - - if (Reanimated?.default && typeof Reanimated.default === "object") { - Reanimated.default.call = () => undefined; - } - - return Reanimated; -}); - describe("FloatingActionButton", () => { let defaultProps: FloatingActionButtonProps; const secondaryButtons = [ diff --git a/packages/pluggableWidgets/floating-action-button-native/src/__tests__/__snapshots__/FloatingActionButton.spec.tsx.snap b/packages/pluggableWidgets/floating-action-button-native/src/__tests__/__snapshots__/FloatingActionButton.spec.tsx.snap index c2492e5a7..85bcd419d 100644 --- a/packages/pluggableWidgets/floating-action-button-native/src/__tests__/__snapshots__/FloatingActionButton.spec.tsx.snap +++ b/packages/pluggableWidgets/floating-action-button-native/src/__tests__/__snapshots__/FloatingActionButton.spec.tsx.snap @@ -23,30 +23,25 @@ exports[`FloatingActionButton renders correct props with secondary buttons 1`] = } > - + diff --git a/packages/pluggableWidgets/floating-action-button-native/src/ui/styles.tsx b/packages/pluggableWidgets/floating-action-button-native/src/ui/styles.tsx index a6802b783..e6c5dc764 100644 --- a/packages/pluggableWidgets/floating-action-button-native/src/ui/styles.tsx +++ b/packages/pluggableWidgets/floating-action-button-native/src/ui/styles.tsx @@ -1,5 +1,5 @@ import { Style } from "@mendix/piw-native-utils-internal"; -import { TextStyle, ViewStyle } from "react-native"; +import { TextStyle, ViewStyle, StyleSheet } from "react-native"; export interface FloatingActionButtonStyle extends Style { container: ViewStyle; @@ -80,3 +80,49 @@ export const defaultFloatingActionButtonStyle: FloatingActionButtonStyle = { marginHorizontal: 15 } }; + +export const styles = StyleSheet.create({ + wrapper: { + justifyContent: "flex-end" + }, + mainButtonBase: { + alignItems: "center", + justifyContent: "center" + }, + secondaryAnchor: { + position: "absolute" + }, + secondaryRow: { + width: "100%", + flexDirection: "row", + alignItems: "center" + }, + rowAlignLeft: { + justifyContent: "flex-start" + }, + rowAlignRight: { + justifyContent: "flex-end" + }, + rowAlignCenter: { + justifyContent: "center" + }, + secondaryButtonBase: { + alignItems: "center", + justifyContent: "center", + flexShrink: 0 + }, + captionInlineContainer: { + flexShrink: 0 + }, + captionBeforeButton: { + marginRight: 8, + alignItems: "flex-end" + }, + captionAfterButton: { + marginLeft: 8, + alignItems: "flex-start" + }, + captionText: { + flexShrink: 0 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50da14752..31887d9d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,9 +499,6 @@ importers: '@mendix/piw-utils-internal': specifier: '*' version: link:../../tools/piw-utils-internal - react-native-reanimated: - specifier: ^3.0.0 - version: 3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.12.0 @@ -6454,12 +6451,6 @@ packages: react: 19.2.3 react-native: 0.84.1 - react-native-is-edge-to-edge@1.1.7: - resolution: {integrity: sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==} - peerDependencies: - react: 19.2.3 - react-native: 0.84.1 - react-native-is-edge-to-edge@1.3.1: resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} peerDependencies: @@ -6540,13 +6531,6 @@ packages: react-native: 0.84.1 react-native-svg: ^9.6.4 - react-native-reanimated@3.19.5: - resolution: {integrity: sha512-bd4AwIkBAaY4BjrgpSoKjEaRG/tXD756F5nGuiH5IMBSKN8tRdUEA8hWZCyIo/R6/kha/tVSoCqodVUACh7ZWw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - react: 19.2.3 - react-native: 0.84.1 - react-native-reanimated@4.3.1: resolution: {integrity: sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==} peerDependencies: @@ -14632,11 +14616,6 @@ snapshots: react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) - react-native-is-edge-to-edge@1.1.7(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): - dependencies: - react: 19.2.3 - react-native: 0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) - react-native-is-edge-to-edge@1.3.1(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -14697,26 +14676,6 @@ snapshots: react-native: 0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) react-native-svg: 15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) - react-native-reanimated@3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - convert-source-map: 2.0.0 - invariant: 2.2.4 - react: 19.2.3 - react-native: 0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) - react-native-is-edge-to-edge: 1.1.7(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) - transitivePeerDependencies: - - supports-color - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3