Repository: callstack/react-native-paper
Pull request #672 (2018-11-22 13:20:39)

feat: smooth icon change in `IconButton`
<!-- Please provide enough information so that others can review your pull request. -->
<!-- Keep pull requests small and focused on a single change. -->

### Motivation

Whenever the icon of `IconButton` changes, the change looks quite hard. This change will introduce the same effect as in `FAB`s.

### Test plan

n/a


callstack-bot: Hey @jaulz, thank you for your pull request 🤗. The documentation from this branch can be viewed [here](https://6087-71323749-gh.circle-artifacts.com/0/docs/index.html). Please remember to update Typescript types if you changed API.
ferrannp: @jaulz can you give an example of use case for this? Some gifs maybe?
jaulz: @ferrannp I am not able to create a gif right now but my use case is that I want to change the icon of an `Appbar.Action` button depending on whether items are selected or not. I could of course render a different component but that transition looks hard and not really appealing. If we use `CrossFadeIcon` instead the change would look nice and smooth and it would not change the API.

Patch:
diff --git a/example/src/FABExample.js b/example/src/FABExample.js
index eccb19540..efeea873f 100644
--- a/example/src/FABExample.js
+++ b/example/src/FABExample.js
@@ -9,6 +9,7 @@ type Props = {
 };
 
 type State = {
+  visible: boolean,
   open: boolean,
 };
 
@@ -16,6 +17,7 @@ class ButtonExample extends React.Component<Props, State> {
   static title = 'Floating Action Button';
 
   state = {
+    visible: true,
     open: false,
   };
 
@@ -29,19 +31,38 @@ class ButtonExample extends React.Component<Props, State> {
     return (
       <View style={[styles.container, { backgroundColor: background }]}>
         <View style={styles.row}>
-          <FAB small icon="add" style={styles.fab} onPress={() => {}} />
-          <FAB icon="favorite" style={styles.fab} onPress={() => {}} />
+          <FAB
+            small
+            icon={this.state.visible ? 'visibility-off' : 'visibility'}
+            style={styles.fab}
+            onPress={() => {
+              this.setState({
+                visible: !this.state.visible,
+              });
+            }}
+          />
+        </View>
+
+        <View style={styles.row}>
+          <FAB
+            icon="favorite"
+            style={styles.fab}
+            onPress={() => {}}
+            visible={this.state.visible}
+          />
           <FAB
             icon="done"
             label="Extended FAB"
             style={styles.fab}
             onPress={() => {}}
+            visible={this.state.visible}
           />
           <FAB
             icon="cancel"
             label="Disabled FAB"
             style={styles.fab}
             onPress={() => {}}
+            visible={this.state.visible}
             disabled
           />
           <Portal>
@@ -60,6 +81,7 @@ class ButtonExample extends React.Component<Props, State> {
                   // do something if the speed dial is open
                 }
               }}
+              visible={this.state.visible}
             />
           </Portal>
         </View>
@@ -76,7 +98,6 @@ const styles = StyleSheet.create({
   },
 
   row: {
-    flex: 1,
     justifyContent: 'center',
     alignItems: 'center',
   },
diff --git a/package.json b/package.json
index 7a6a980c1..73ef312ca 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-native-paper",
-  "version": "2.3.0",
+  "version": "2.4.0",
   "description": "Material design for React Native",
   "main": "src/index.js",
   "typings": "typings/index.d.ts",
diff --git a/src/components/Appbar/AppbarContent.js b/src/components/Appbar/AppbarContent.js
index 3159c7de8..7ad85b60b 100644
--- a/src/components/Appbar/AppbarContent.js
+++ b/src/components/Appbar/AppbarContent.js
@@ -1,7 +1,12 @@
 /* @flow */
 
 import * as React from 'react';
-import { View, StyleSheet, Platform } from 'react-native';
+import {
+  View,
+  StyleSheet,
+  Platform,
+  TouchableWithoutFeedback,
+} from 'react-native';
 import color from 'color';
 
 import Text from '../Typography/Text';
@@ -32,6 +37,10 @@ type Props = $RemoveChildren<typeof View> & {|
    * Style for the subtitle.
    */
   subtitleStyle?: any,
+  /**
+   * Function to execute on press.
+   */
+  onPress?: () => mixed,
   style?: any,
   /**
    * @optional
@@ -50,6 +59,7 @@ class AppbarContent extends React.Component<Props> {
       color: titleColor = black,
       subtitle,
       subtitleStyle,
+      onPress,
       style,
       titleStyle,
       theme,
@@ -64,31 +74,34 @@ class AppbarContent extends React.Component<Props> {
       .string();
 
     return (
-      <View style={[styles.container, style]} {...rest}>
-        <Text
-          style={[
-            {
-              color: titleColor,
-              fontFamily: Platform.OS === 'ios' ? fonts.regular : fonts.medium,
-            },
-            styles.title,
-            titleStyle,
-          ]}
-          numberOfLines={1}
-          accessibilityTraits="header"
-          accessibilityRole="header"
-        >
-          {title}
-        </Text>
-        {subtitle ? (
+      <TouchableWithoutFeedback onPress={onPress}>
+        <View style={[styles.container, style]} {...rest}>
           <Text
-            style={[styles.subtitle, { color: subtitleColor }, subtitleStyle]}
+            style={[
+              {
+                color: titleColor,
+                fontFamily:
+                  Platform.OS === 'ios' ? fonts.regular : fonts.medium,
+              },
+              styles.title,
+              titleStyle,
+            ]}
             numberOfLines={1}
+            accessibilityTraits="header"
+            accessibilityRole="header"
           >
-            {subtitle}
+            {title}
           </Text>
-        ) : null}
-      </View>
+          {subtitle ? (
+            <Text
+              style={[styles.subtitle, { color: subtitleColor }, subtitleStyle]}
+              numberOfLines={1}
+            >
+              {subtitle}
+            </Text>
+          ) : null}
+        </View>
+      </TouchableWithoutFeedback>
     );
   }
 }
diff --git a/src/components/FAB/FAB.js b/src/components/FAB/FAB.js
index 57a91a5f2..f1c4d3978 100644
--- a/src/components/FAB/FAB.js
+++ b/src/components/FAB/FAB.js
@@ -2,7 +2,7 @@
 
 import color from 'color';
 import * as React from 'react';
-import { View, StyleSheet } from 'react-native';
+import { Animated, View, StyleSheet } from 'react-native';
 import FABGroup from './FABGroup';
 import Surface from '../Surface';
 import CrossFadeIcon from '../CrossFadeIcon';
@@ -39,6 +39,10 @@ type Props = $RemoveChildren<typeof Surface> & {|
    * Whether `FAB` is disabled. A disabled button is greyed out and `onPress` is not called on touch.
    */
   disabled?: boolean,
+  /**
+   * Whether `FAB` is currently visible.
+   */
+  visible: boolean,
   /**
    * Function to execute on press.
    */
@@ -50,6 +54,10 @@ type Props = $RemoveChildren<typeof Surface> & {|
   theme: Theme,
 |};
 
+type State = {
+  visibility: Animated.Value,
+};
+
 /**
  * A floating action button represents the primary action in an application.
  *
@@ -85,10 +93,38 @@ type Props = $RemoveChildren<typeof Surface> & {|
  * export default MyComponent;
  * ```
  */
-class FAB extends React.Component<Props> {
+class FAB extends React.Component<Props, State> {
   // @component ./FABGroup.js
   static Group = FABGroup;
 
+  static defaultProps = {
+    visible: true,
+  };
+
+  state = {
+    visibility: new Animated.Value(this.props.visible ? 1 : 0),
+  };
+
+  componentDidUpdate(prevProps) {
+    if (this.props.visible === prevProps.visible) {
+      return;
+    }
+
+    if (this.props.visible) {
+      Animated.timing(this.state.visibility, {
+        toValue: 1,
+        duration: 200,
+        useNativeDriver: true,
+      }).start();
+    } else {
+      Animated.timing(this.state.visibility, {
+        toValue: 0,
+        duration: 150,
+        useNativeDriver: true,
+      }).start();
+    }
+  }
+
   render() {
     const {
       small,
@@ -102,6 +138,7 @@ class FAB extends React.Component<Props> {
       style,
       ...rest
     } = this.props;
+    const { visibility } = this.state;
 
     const disabledColor = color(theme.dark ? white : black)
       .alpha(0.12)
@@ -135,7 +172,15 @@ class FAB extends React.Component<Props> {
       <Surface
         {...rest}
         style={[
-          { backgroundColor },
+          {
+            backgroundColor,
+            opacity: visibility,
+            transform: [
+              {
+                scale: visibility,
+              },
+            ],
+          },
           styles.container,
           disabled && styles.disabled,
           style,
diff --git a/src/components/FAB/FABGroup.js b/src/components/FAB/FABGroup.js
index 3c7320626..b35526836 100644
--- a/src/components/FAB/FABGroup.js
+++ b/src/components/FAB/FABGroup.js
@@ -62,6 +62,10 @@ type Props = {|
    * The open state needs to be updated when it's called, otherwise the change is dropped.
    */
   onStateChange: (state: { open: boolean }) => mixed,
+  /**
+   * Whether `FAB` is currently visible.
+   */
+  visible: boolean,
   /**
    * Style for the group. You can use it to pass additional styles if you need.
    * For example, you can set an additional margin if you have a tab bar at the bottom.
@@ -194,6 +198,7 @@ class FABGroup extends React.Component<Props, State> {
       accessibilityLabel,
       theme,
       style,
+      visible,
     } = this.props;
     const { colors } = theme;
 
@@ -310,6 +315,7 @@ class FABGroup extends React.Component<Props, State> {
           accessibilityComponentType="button"
           accessibilityRole="button"
           style={styles.fab}
+          visible={visible}
         />
       </View>
     );
diff --git a/src/components/IconButton.js b/src/components/IconButton.js
index 67f715a05..8f6426e4d 100644
--- a/src/components/IconButton.js
+++ b/src/components/IconButton.js
@@ -5,7 +5,7 @@ import { View, StyleSheet } from 'react-native';
 import color from 'color';
 
 import TouchableRipple from './TouchableRipple';
-import Icon from './Icon';
+import CrossFadeIcon from './CrossFadeIcon';
 import { withTheme } from '../core/theming';
 import type { IconSource } from './Icon';
 import type { Theme, $RemoveChildren } from '../types';
@@ -111,7 +111,7 @@ const IconButton = ({
       {...rest}
     >
       <View>
-        <Icon color={iconColor} source={icon} size={size} />
+        <CrossFadeIcon color={iconColor} source={icon} size={size} />
       </View>
     </TouchableRipple>
   );
diff --git a/src/components/TextInput.js b/src/components/TextInput.js
index 035da912f..8ad1d437f 100644
--- a/src/components/TextInput.js
+++ b/src/components/TextInput.js
@@ -472,7 +472,8 @@ class TextInput extends React.Component<Props, State> {
           translateX: this.state.labeled.interpolate({
             inputRange: [0, 1],
             outputRange: [
-              -(1 - MINIMIZED_LABEL_FONT_SIZE / MAXIMIZED_LABEL_FONT_SIZE) *
+              (I18nManager.isRTL ? 1 : -1) *
+                (1 - MINIMIZED_LABEL_FONT_SIZE / MAXIMIZED_LABEL_FONT_SIZE) *
                 (this.state.labelLayout.width / 2),
               0,
             ],
diff --git a/src/components/TouchableRipple.js b/src/components/TouchableRipple.js
index 7ecf0b46e..fad338f4f 100644
--- a/src/components/TouchableRipple.js
+++ b/src/components/TouchableRipple.js
@@ -13,6 +13,7 @@ import { withTheme } from '../core/theming';
 import type { Theme } from '../types';
 
 const ANDROID_VERSION_LOLLIPOP = 21;
+const ANDROID_VERSION_NOUGAT = 27;
 
 type Props = React.ElementConfig<typeof TouchableWithoutFeedback> & {|
   /**
@@ -83,7 +84,9 @@ class TouchableRipple extends React.Component<Props, void> {
    * Whether ripple effect is supported.
    */
   static supported =
-    Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
+    Platform.OS === 'android' &&
+    Platform.Version >= ANDROID_VERSION_LOLLIPOP &&
+    Platform.Version <= ANDROID_VERSION_NOUGAT;
 
   render() {
     const {
diff --git a/typings/components/FAB.d.ts b/typings/components/FAB.d.ts
index 56d6f7c45..bf75b0473 100644
--- a/typings/components/FAB.d.ts
+++ b/typings/components/FAB.d.ts
@@ -19,6 +19,7 @@ export interface FABGroupProps {
   onPress?: () => any;
   open: boolean;
   onStateChange: (state: { open: boolean }) => any;
+  visible?: boolean;
   style?: any;
   theme?: ThemeShape;
 }
@@ -30,6 +31,7 @@ export interface FABProps extends ViewProps {
   small?: boolean;
   color?: string;
   disabled?: boolean;
+  visible?: boolean;
   onPress?: () => any;
   theme?: ThemeShape;
 }


Test patch:
diff --git a/src/components/__tests__/__snapshots__/FAB.test.js.snap b/src/components/__tests__/__snapshots__/FAB.test.js.snap
index 6b9ab3bb4..ca6462df4 100644
--- a/src/components/__tests__/__snapshots__/FAB.test.js.snap
+++ b/src/components/__tests__/__snapshots__/FAB.test.js.snap
@@ -7,6 +7,7 @@ exports[`renders extended FAB 1`] = `
       "backgroundColor": "#03dac4",
       "borderRadius": 28,
       "elevation": 6,
+      "opacity": 1,
       "shadowColor": "#000000",
       "shadowOffset": Object {
         "height": 5,
@@ -14,8 +15,14 @@ exports[`renders extended FAB 1`] = `
       },
       "shadowOpacity": 0.24,
       "shadowRadius": 6,
+      "transform": Array [
+        Object {
+          "scale": 1,
+        },
+      ],
     }
   }
+  visible={true}
 >
   <View
     accessibilityLabel="Add items"
@@ -152,6 +159,7 @@ exports[`renders normal FAB 1`] = `
       "backgroundColor": "#03dac4",
       "borderRadius": 28,
       "elevation": 6,
+      "opacity": 1,
       "shadowColor": "#000000",
       "shadowOffset": Object {
         "height": 5,
@@ -159,8 +167,14 @@ exports[`renders normal FAB 1`] = `
       },
       "shadowOpacity": 0.24,
       "shadowRadius": 6,
+      "transform": Array [
+        Object {
+          "scale": 1,
+        },
+      ],
     }
   }
+  visible={true}
 >
   <View
     accessibilityRole="button"
@@ -273,6 +287,7 @@ exports[`renders small FAB 1`] = `
       "backgroundColor": "#03dac4",
       "borderRadius": 28,
       "elevation": 6,
+      "opacity": 1,
       "shadowColor": "#000000",
       "shadowOffset": Object {
         "height": 5,
@@ -280,8 +295,14 @@ exports[`renders small FAB 1`] = `
       },
       "shadowOpacity": 0.24,
       "shadowRadius": 6,
+      "transform": Array [
+        Object {
+          "scale": 1,
+        },
+      ],
     }
   }
+  visible={true}
 >
   <View
     accessibilityRole="button"
diff --git a/src/components/__tests__/__snapshots__/IconButton.test.js.snap b/src/components/__tests__/__snapshots__/IconButton.test.js.snap
index b588ac967..9acc4f86e 100644
--- a/src/components/__tests__/__snapshots__/IconButton.test.js.snap
+++ b/src/components/__tests__/__snapshots__/IconButton.test.js.snap
@@ -44,40 +44,73 @@ exports[`renders disabled icon button 1`] = `
   <View
     style={null}
   >
-    <Text
-      accessibilityElementsHidden={true}
-      allowFontScaling={false}
-      importantForAccessibility="no-hide-descendants"
-      pointerEvents="none"
+    <View
       style={
         Array [
           Object {
-            "color": "#000000",
-            "fontSize": 24,
+            "alignItems": "center",
+            "justifyContent": "center",
           },
-          Array [
-            Object {
-              "transform": Array [
-                Object {
-                  "scaleX": 1,
-                },
-              ],
-            },
-            Object {
-              "backgroundColor": "transparent",
-            },
-          ],
           Object {
-            "fontFamily": "Material Icons",
-            "fontStyle": "normal",
-            "fontWeight": "normal",
+            "height": 24,
+            "width": 24,
           },
-          Object {},
         ]
       }
     >
-      
-    </Text>
+      <View
+        style={
+          Object {
+            "bottom": 0,
+            "left": 0,
+            "opacity": 1,
+            "position": "absolute",
+            "right": 0,
+            "top": 0,
+            "transform": Array [
+              Object {
+                "rotate": "0deg",
+              },
+            ],
+          }
+        }
+      >
+        <Text
+          accessibilityElementsHidden={true}
+          allowFontScaling={false}
+          importantForAccessibility="no-hide-descendants"
+          pointerEvents="none"
+          style={
+            Array [
+              Object {
+                "color": "#000000",
+                "fontSize": 24,
+              },
+              Array [
+                Object {
+                  "transform": Array [
+                    Object {
+                      "scaleX": 1,
+                    },
+                  ],
+                },
+                Object {
+                  "backgroundColor": "transparent",
+                },
+              ],
+              Object {
+                "fontFamily": "Material Icons",
+                "fontStyle": "normal",
+                "fontWeight": "normal",
+              },
+              Object {},
+            ]
+          }
+        >
+          
+        </Text>
+      </View>
+    </View>
   </View>
 </View>
 `;
@@ -119,40 +152,73 @@ exports[`renders icon button by default 1`] = `
   <View
     style={null}
   >
-    <Text
-      accessibilityElementsHidden={true}
-      allowFontScaling={false}
-      importantForAccessibility="no-hide-descendants"
-      pointerEvents="none"
+    <View
       style={
         Array [
           Object {
-            "color": "#000000",
-            "fontSize": 24,
+            "alignItems": "center",
+            "justifyContent": "center",
           },
-          Array [
-            Object {
-              "transform": Array [
-                Object {
-                  "scaleX": 1,
-                },
-              ],
-            },
-            Object {
-              "backgroundColor": "transparent",
-            },
-          ],
           Object {
-            "fontFamily": "Material Icons",
-            "fontStyle": "normal",
-            "fontWeight": "normal",
+            "height": 24,
+            "width": 24,
           },
-          Object {},
         ]
       }
     >
-      
-    </Text>
+      <View
+        style={
+          Object {
+            "bottom": 0,
+            "left": 0,
+            "opacity": 1,
+            "position": "absolute",
+            "right": 0,
+            "top": 0,
+            "transform": Array [
+              Object {
+                "rotate": "0deg",
+              },
+            ],
+          }
+        }
+      >
+        <Text
+          accessibilityElementsHidden={true}
+          allowFontScaling={false}
+          importantForAccessibility="no-hide-descendants"
+          pointerEvents="none"
+          style={
+            Array [
+              Object {
+                "color": "#000000",
+                "fontSize": 24,
+              },
+              Array [
+                Object {
+                  "transform": Array [
+                    Object {
+                      "scaleX": 1,
+                    },
+                  ],
+                },
+                Object {
+                  "backgroundColor": "transparent",
+                },
+              ],
+              Object {
+                "fontFamily": "Material Icons",
+                "fontStyle": "normal",
+                "fontWeight": "normal",
+              },
+              Object {},
+            ]
+          }
+        >
+          
+        </Text>
+      </View>
+    </View>
   </View>
 </View>
 `;
@@ -194,40 +260,73 @@ exports[`renders icon button with color 1`] = `
   <View
     style={null}
   >
-    <Text
-      accessibilityElementsHidden={true}
-      allowFontScaling={false}
-      importantForAccessibility="no-hide-descendants"
-      pointerEvents="none"
+    <View
       style={
         Array [
           Object {
-            "color": "#e91e63",
-            "fontSize": 24,
+            "alignItems": "center",
+            "justifyContent": "center",
           },
-          Array [
-            Object {
-              "transform": Array [
-                Object {
-                  "scaleX": 1,
-                },
-              ],
-            },
-            Object {
-              "backgroundColor": "transparent",
-            },
-          ],
           Object {
-            "fontFamily": "Material Icons",
-            "fontStyle": "normal",
-            "fontWeight": "normal",
+            "height": 24,
+            "width": 24,
           },
-          Object {},
         ]
       }
     >
-      
-    </Text>
+      <View
+        style={
+          Object {
+            "bottom": 0,
+            "left": 0,
+            "opacity": 1,
+            "position": "absolute",
+            "right": 0,
+            "top": 0,
+            "transform": Array [
+              Object {
+                "rotate": "0deg",
+              },
+            ],
+          }
+        }
+      >
+        <Text
+          accessibilityElementsHidden={true}
+          allowFontScaling={false}
+          importantForAccessibility="no-hide-descendants"
+          pointerEvents="none"
+          style={
+            Array [
+              Object {
+                "color": "#e91e63",
+                "fontSize": 24,
+              },
+              Array [
+                Object {
+                  "transform": Array [
+                    Object {
+                      "scaleX": 1,
+                    },
+                  ],
+                },
+                Object {
+                  "backgroundColor": "transparent",
+                },
+              ],
+              Object {
+                "fontFamily": "Material Icons",
+                "fontStyle": "normal",
+                "fontWeight": "normal",
+              },
+              Object {},
+            ]
+          }
+        >
+          
+        </Text>
+      </View>
+    </View>
   </View>
 </View>
 `;
@@ -269,40 +368,73 @@ exports[`renders icon button with size 1`] = `
   <View
     style={null}
   >
-    <Text
-      accessibilityElementsHidden={true}
-      allowFontScaling={false}
-      importantForAccessibility="no-hide-descendants"
-      pointerEvents="none"
+    <View
       style={
         Array [
           Object {
-            "color": "#000000",
-            "fontSize": 30,
+            "alignItems": "center",
+            "justifyContent": "center",
           },
-          Array [
-            Object {
-              "transform": Array [
-                Object {
-                  "scaleX": 1,
-                },
-              ],
-            },
-            Object {
-              "backgroundColor": "transparent",
-            },
-          ],
           Object {
-            "fontFamily": "Material Icons",
-            "fontStyle": "normal",
-            "fontWeight": "normal",
+            "height": 30,
+            "width": 30,
           },
-          Object {},
         ]
       }
     >
-      
-    </Text>
+      <View
+        style={
+          Object {
+            "bottom": 0,
+            "left": 0,
+            "opacity": 1,
+            "position": "absolute",
+            "right": 0,
+            "top": 0,
+            "transform": Array [
+              Object {
+                "rotate": "0deg",
+              },
+            ],
+          }
+        }
+      >
+        <Text
+          accessibilityElementsHidden={true}
+          allowFontScaling={false}
+          importantForAccessibility="no-hide-descendants"
+          pointerEvents="none"
+          style={
+            Array [
+              Object {
+                "color": "#000000",
+                "fontSize": 30,
+              },
+              Array [
+                Object {
+                  "transform": Array [
+                    Object {
+                      "scaleX": 1,
+                    },
+                  ],
+                },
+                Object {
+                  "backgroundColor": "transparent",
+                },
+              ],
+              Object {
+                "fontFamily": "Material Icons",
+                "fontStyle": "normal",
+                "fontWeight": "normal",
+              },
+              Object {},
+            ]
+          }
+        >
+          
+        </Text>
+      </View>
+    </View>
   </View>
 </View>
 `;