Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@
"patchedDependencies": {
"@mendix/pluggable-widgets-tools@11.8.0": "patches/@mendix+pluggable-widgets-tools+11.8.0.patch",
"@ptomasroos/react-native-multi-slider@1.0.0": "patches/@ptomasroos+react-native-multi-slider+1.0.0.patch",
"react-native-action-button@2.8.5": "patches/react-native-action-button+2.8.5.patch",
"react-native-gesture-handler@2.30.0": "patches/react-native-gesture-handler+2.30.0.patch",
"react-native-slider@0.11.0": "patches/react-native-slider+0.11.0.patch",
"react-native-snap-carousel@3.9.1": "patches/react-native-snap-carousel+3.9.1.patch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Changed

- Replaced `react-native-action-button` library with custom implementation using React Native's Animated API for better maintainability and reduced bundle size.

- Removed deprecated `react-native-prop-types` dependency.

## [4.1.0] - 2024-12-3

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "floating-action-button-native",
"widgetName": "FloatingActionButton",
"version": "4.2.0",
"version": "4.2.1",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand All @@ -20,9 +20,7 @@
},
"dependencies": {
"@mendix/piw-native-utils-internal": "*",
"@mendix/piw-utils-internal": "*",
"deprecated-react-native-prop-types": "^4.0.0",
"react-native-action-button": "^2.8.5"
"@mendix/piw-utils-internal": "*"
},
"devDependencies": {
"@mendix/pluggable-widgets-tools": "*"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,132 +1,246 @@
import { flattenStyles } from "@mendix/piw-native-utils-internal";
import { Icon } from "mendix/components/native/Icon";
import { Component, JSX } from "react";
import { View } from "react-native";
import ActionButton from "react-native-action-button";
import { ReactElement, useState, useRef, useEffect } from "react";
import { View, TouchableOpacity, Text, ViewStyle, Animated } from "react-native";

import { FloatingActionButtonProps } from "../typings/FloatingActionButtonProps";
import { defaultFloatingActionButtonStyle, FloatingActionButtonStyle } from "./ui/styles";
import { executeAction } from "@mendix/piw-utils-internal";

interface State {
active: boolean;
}

const defaultIconSource = { type: "glyph", iconClass: "glyphicon-plus" } as const;
const defaultActiveIconSource = { type: "glyph", iconClass: "glyphicon-remove" } as const;

export class FloatingActionButton extends Component<FloatingActionButtonProps<FloatingActionButtonStyle>, State> {
readonly state: State = {
active: false
export function FloatingActionButton(props: FloatingActionButtonProps<FloatingActionButtonStyle>): ReactElement {
const [isOpen, setIsOpen] = useState(false);

const styles = flattenStyles(defaultFloatingActionButtonStyle, props.style);
const animation = useRef(new Animated.Value(0)).current;

useEffect(() => {
Animated.spring(animation, {
toValue: isOpen ? 1 : 0,
friction: 7,
tension: 40,
useNativeDriver: true
}).start();
}, [isOpen, animation]);

const iconRotation = animation.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "180deg"]
});

const handlePress = (): void => {
if (props.secondaryButtons?.length > 0) {
setIsOpen(!isOpen);
} else {
executeAction(props.onClick);
}
};

private readonly styles = flattenStyles(defaultFloatingActionButtonStyle, this.props.style);
private readonly onPressHandler = this.onPress.bind(this);
private readonly renderIconHandler = this.renderIcon.bind(this);

render(): JSX.Element {
const buttonStyle = { ...this.styles.button };
delete buttonStyle.rippleColor;

return (
<ActionButton
size={this.styles.button.size}
style={this.styles.container}
shadowStyle={buttonStyle}
buttonColor={"transparent"}
nativeFeedbackRippleColor={this.styles.button.rippleColor}
position={this.props.horizontalPosition}
verticalOrientation={this.verticalOrientation}
renderIcon={this.renderIconHandler}
degrees={this.props.secondaryButtons.length > 0 ? 180 : 0}
onPress={this.onPressHandler}
fixNativeFeedbackRadius
backgroundTappable
activeOpacity={0.2}
elevation={buttonStyle.elevation}
offsetX={0}
offsetY={0}
zIndex={999}
testID={this.props.name}
>
{this.renderButtons()}
</ActionButton>
);
}

private renderIcon(): JSX.Element {
const { icon, iconActive } = this.props;
const iconSource = icon && icon.value ? icon.value : defaultIconSource;
const activeIconSource = iconActive && iconActive.value ? iconActive.value : defaultActiveIconSource;

const isActive = this.state.active && this.props.secondaryButtons.length > 0;
const source = isActive ? activeIconSource : iconSource;
const style = isActive ? { transform: [{ rotate: "-180deg" }] } : {};
const buttonContainerStyle = [this.styles.button, this.styles.buttonContainer];

return (
<View testID={"FloatingAction$IconView"} style={buttonContainerStyle}>
<View style={[style, this.styles.buttonIconContainer]}>
<Icon icon={source} size={this.styles.buttonIcon.size} color={this.styles.buttonIcon.color} />
</View>
</View>
);
}
const margin = (styles.container.margin as number) ?? 30;
const mainButtonSize = styles.button.size ?? 54;
const secondaryButtonSize = styles.secondaryButton.size ?? 40;

private renderButtons(): JSX.Element[] | undefined {
return (
this.props.secondaryButtons &&
this.props.secondaryButtons.map((button, index) => {
return (
<ActionButton.Item
testID={`${this.props.name}$button${index}`}
key={`button${index}`}
size={this.styles.secondaryButton.size}
title={button.caption && button.caption.value}
shadowStyle={this.styles.secondaryButton}
buttonColor={this.styles.secondaryButton.backgroundColor as string}
nativeFeedbackRippleColor={"transparent"}
textStyle={this.styles.secondaryButtonCaption}
textContainerStyle={this.styles.secondaryButtonCaptionContainer}
onPress={() => {
this.setState({ active: false });
executeAction(button.onClick);
}}
activeOpacity={0.2}
spaceBetween={0}
>
{button.icon.value && (
<Icon
icon={button.icon.value}
size={this.styles.secondaryButtonIcon.size}
color={this.styles.secondaryButtonIcon.color}
/>
)}
</ActionButton.Item>
);
})
);
}

private get verticalOrientation(): "up" | "down" {
switch (this.props.verticalPosition) {
case "bottom":
return "up";
case "top":
return "down";
const isVerticalUp = props.verticalPosition === "bottom";
const verticalDirection = isVerticalUp ? -1 : 1;
const secondaryButtonGap = 20;
const mainToFirstButtonGap = secondaryButtonGap;
const firstButtonOffset = mainButtonSize / 2 + secondaryButtonSize / 2 + mainToFirstButtonGap;
const buttonSpacing = secondaryButtonSize + secondaryButtonGap;

const labelOnRight = props.horizontalPosition === "left";
const captionSpacing = (styles.secondaryButtonCaptionContainer.marginHorizontal as number) ?? 15;

// Horizontal positioning using edge-relative styles instead of pixel offsets
const getHorizontalPosition = (buttonSize: number): ViewStyle => {
const centerAlignmentOffset = (mainButtonSize - buttonSize) / 2;

switch (props.horizontalPosition) {
case "left":
return { left: margin + centerAlignmentOffset };
case "right":
return { right: margin + centerAlignmentOffset };
case "center":
default:
return "down";
return { left: "50%", marginLeft: -buttonSize / 2 };
}
}
};

private onPress(): void {
if (this.props.secondaryButtons && this.props.secondaryButtons.length > 0) {
// eslint-disable-next-line react/no-access-state-in-setstate
this.setState({ active: !this.state.active });
const mainButtonHorizontal = getHorizontalPosition(mainButtonSize);
const secondaryButtonHorizontal = getHorizontalPosition(secondaryButtonSize);
const secondaryCenterAlignmentOffset = (mainButtonSize - secondaryButtonSize) / 2;

return;
const getLabelHorizontalPosition = (): ViewStyle => {
switch (props.horizontalPosition) {
case "left":
return { left: margin + secondaryCenterAlignmentOffset + secondaryButtonSize + captionSpacing };
case "right":
return { right: margin + secondaryCenterAlignmentOffset + secondaryButtonSize + captionSpacing };
case "center":
default:
return labelOnRight
? { left: "50%", marginLeft: secondaryButtonSize / 2 + captionSpacing }
: { right: "50%", marginRight: secondaryButtonSize / 2 + captionSpacing };
}
};

const secondaryLabelHorizontal = getLabelHorizontalPosition();

const containerStyle: ViewStyle = {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
direction: "ltr",
zIndex: 999,
pointerEvents: "box-none"
};

const mainButtonTop = props.verticalPosition === "top" ? margin : undefined;
const mainButtonBottom = props.verticalPosition === "bottom" ? margin : undefined;

const currentIcon = (() => {
const shouldShowActive = isOpen && props.secondaryButtons.length > 0;
return shouldShowActive
? props.iconActive?.value || defaultActiveIconSource
: props.icon?.value || defaultIconSource;
})();

return (
<View style={containerStyle}>
{/* Secondary buttons */}
{props.secondaryButtons?.map((button, index) => {
const yOffset = verticalDirection * (firstButtonOffset + index * buttonSpacing);
const staggerDelay = Math.min(index * 0.08, 0.4);

const opacity = animation.interpolate({
inputRange: [0, staggerDelay, Math.min(staggerDelay + 0.2, 1)],
outputRange: [0, 0, 1],
extrapolate: "clamp"
});

const translateY = animation.interpolate({
inputRange: [0, 1],
outputRange: [0, yOffset]
});

executeAction(this.props.onClick);
}
const buttonTop = props.verticalPosition === "top" ? margin : undefined;
const buttonBottom = props.verticalPosition === "bottom" ? margin : undefined;

return (
<View key={`button${index}`} pointerEvents="box-none">
{/* Button */}
<Animated.View
style={[
{
position: "absolute",
opacity,
transform: [{ translateY }],
top: buttonTop,
bottom: buttonBottom,
width: secondaryButtonSize,
height: secondaryButtonSize
},
secondaryButtonHorizontal
]}
>
<TouchableOpacity
testID={`${props.name}$button${index}`}
accessibilityRole="button"
accessibilityLabel={button.caption?.value}
style={[
styles.secondaryButton,
{
width: secondaryButtonSize,
height: secondaryButtonSize,
borderRadius: secondaryButtonSize / 2,
justifyContent: "center",
alignItems: "center"
}
]}
onPress={() => {
setIsOpen(false);
executeAction(button.onClick);
}}
activeOpacity={0.2}
>
{button.icon.value && (
<Icon
icon={button.icon.value}
size={styles.secondaryButtonIcon.size}
color={styles.secondaryButtonIcon.color}
/>
)}
</TouchableOpacity>
</Animated.View>

{/* Label */}
{button.caption?.value && (
<Animated.View
style={[
styles.secondaryButtonCaptionContainer,
{
position: "absolute",
opacity,
transform: [{ translateY }],
top: buttonTop,
bottom: buttonBottom,
height: secondaryButtonSize,
justifyContent: "center",
alignItems: labelOnRight ? "flex-start" : "flex-end"
},
secondaryLabelHorizontal
]}
>
<Text numberOfLines={1} style={styles.secondaryButtonCaption}>
{button.caption.value}
</Text>
</Animated.View>
)}
</View>
);
})}

{/* Main FAB button */}
<View
style={[
{
position: "absolute",
top: mainButtonTop,
bottom: mainButtonBottom,
width: mainButtonSize,
height: mainButtonSize
},
mainButtonHorizontal
]}
>
<TouchableOpacity
testID={props.name}
accessibilityRole="button"
accessibilityLabel="Floating action button"
accessibilityState={{ expanded: isOpen }}
onPress={handlePress}
activeOpacity={0.2}
style={[
styles.button,
styles.buttonContainer,
{
width: mainButtonSize,
height: mainButtonSize,
borderRadius: mainButtonSize / 2
}
]}
>
<Animated.View
testID={`${props.name}$IconView`}
style={[styles.buttonIconContainer, { transform: [{ rotate: iconRotation }] }]}
>
<Icon icon={currentIcon} size={styles.buttonIcon.size} color={styles.buttonIcon.color} />
</Animated.View>
</TouchableOpacity>
</View>
</View>
);
}
Loading
Loading