Skip to content

Commit

Permalink
fix: Switch support Controlled mode (#1325)
Browse files Browse the repository at this point in the history
  • Loading branch information
1uokun committed Feb 22, 2024
1 parent 3f82ac0 commit 3c67bb2
Show file tree
Hide file tree
Showing 11 changed files with 1,010 additions and 828 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ toc: false
- 🆕 Support (`precision` `filter` ) new props
- ⚡️ Deprecated (<del>`mode`</del>)prop; date format by [Day.js](https://day.js.org/docs/en/parse/string-format)
- ❗️Delete **ImagePicker** and remove dependence `@react-native-camera-roll/camera-roll`
- **Switch**
- fix: `checked` prop support controlled mode [#1325](https://github.com/ant-design/ant-design-mobile-rn/issues/1325)
- feat: `onChange` prop when the Promise is returned, the loading status will be displayed automatically

### 5.0.5
`2023-11-08`
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ toc: false
---

### 5.1.0
`2024-02-20`
`2024-02-23`
- 重构 **Picker** & **PickerView**
- 🔥 重构开发并移除 `@react-native-picker/picker` 依赖
- 💄 基于 `ScrollView {snapToInterval}` 开发并支持`web`
Expand All @@ -26,6 +26,9 @@ toc: false
- 🆕 新增 (`precision` `filter` ) 属性支持
- ⚡️ 废弃(<del>`mode`</del>)属性;时间格式引用[Day.js](https://day.js.org/docs/zh-CN/parse/string-format)
- ❗️删除 **ImagePicker** 并移除 `@react-native-camera-roll/camera-roll` 依赖
- **Switch**
- fix: `checked`属性支持全受控模式 [#1325](https://github.com/ant-design/ant-design-mobile-rn/issues/1325)
- feat: `onChange`属性当返回 Promise 时,会自动显示加载状态

### 5.0.5
`2023-11-08`
Expand Down
1 change: 1 addition & 0 deletions components/_util/hooks/useAnimations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function useAnimatedTiming(): [Animated.Value, animateType] {
useNativeDriver,
}).start()
},
// @ts-ignore
[animatedValue],
)
var [animatedValue] = useAnimate({
Expand Down
5 changes: 5 additions & 0 deletions components/_util/isPromise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function isPromise(obj: unknown): obj is Promise<unknown> {
return (
!!obj && typeof obj === 'object' && typeof (obj as any).then === 'function'
)
}
6 changes: 4 additions & 2 deletions components/switch/PropsType.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React from 'react'
import { ColorValue } from 'react-native'

Expand All @@ -15,6 +14,9 @@ export interface SwitchPropsType {
title?: string
checkedChildren?: string | React.ReactNode
unCheckedChildren?: string | React.ReactNode
onChange?: (checked: boolean) => void
onChange?: (checked: boolean) => void | Promise<void>
/**
* @deprecated
*/
onPress?: (checked: boolean) => void
}
157 changes: 93 additions & 64 deletions components/switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import classNames from 'classnames'
import useMergedState from 'rc-util/lib/hooks/useMergedState'
import * as React from 'react'
import { Animated, Easing, StyleProp, View, ViewStyle } from 'react-native'
import {
Animated,
Easing,
Pressable,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import devWarning from '../_util/devWarning'
import { useAnimatedTiming } from '../_util/hooks/useAnimations'
import { isPromise } from '../_util/isPromise'
import RNActivityIndicator from '../activity-indicator'
import ButtonWave from '../button/ButtonWave'
import { WithTheme, WithThemeStyles } from '../style'
import AntmView from '../view/index'
import devWarning from '../_util/devWarning'
import { useAnimatedTiming } from '../_util/hooks/useAnimations'
import { SwitchPropsType } from './PropsType'
import SwitchStyles, { SwitchStyle } from './style/index'

Expand All @@ -21,6 +28,7 @@ export interface SwitchProps
const AnimatedView = Animated.createAnimatedComponent(AntmView)
const AntmSwitch = ({
prefixCls = 'switch',
style,
checked,
defaultChecked,
disabled,
Expand All @@ -40,29 +48,38 @@ const AntmSwitch = ({
'Switch',
'`value` is not a valid prop, do you mean `checked`?',
)
// Compatible with old code : checked without onChange was alse onControlled
const checkedRef = React.useRef<undefined | boolean>()
if (checkedRef.current === undefined) {
checkedRef.current = checked ?? defaultChecked
}
const [innerChecked, setInnerChecked] = useMergedState<boolean>(false, {
value: checkedRef.current,
value: checked,
defaultValue: defaultChecked,
onChange: onChange,
})
const [innerLoading, setInnerLoading] = useMergedState<boolean>(false, {
value: loading,
})

const PADDING = 11 // switch旁白最低宽度
const TRACK_PADDING = 5 // switch轨道按压变形宽度
const BORDER_WIDTH = 2 // switch轨道边框宽度

// switch height measure
const [itemHeight, setHeight] = React.useState<number>(31)
const wrapperMeasure = React.useCallback((e) => {
setHeight(e.nativeEvent.layout.height)
}, [])

//disabled when loading
disabled = disabled || loading
disabled = disabled || innerLoading

// animate1
const [animatedValue, animate] = useAnimatedTiming()
const transitionMargin = {
marginLeft: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [25, 7],
outputRange: [itemHeight - BORDER_WIDTH, BORDER_WIDTH],
}),
marginRight: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [7, 25],
outputRange: [BORDER_WIDTH, itemHeight - BORDER_WIDTH],
}),
}

Expand All @@ -71,48 +88,59 @@ const AntmSwitch = ({
const transitionWidth = {
width: animatedValue2.interpolate({
inputRange: [0, 1],
outputRange: [22, 28],
outputRange: [
itemHeight - BORDER_WIDTH * 2,
itemHeight - BORDER_WIDTH * 2 + TRACK_PADDING,
],
}),
left: !innerChecked
? animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 10],
outputRange: [BORDER_WIDTH, PADDING],
})
: undefined,
right: innerChecked
? animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [10, 0],
outputRange: [PADDING, BORDER_WIDTH],
})
: 0,
}

//initial animate
React.useEffect(() => {
if (checkedRef.current) {
if (innerChecked) {
animate({})
animate2({ toValue: 0 })
} else {
animate({ toValue: 0 })
}
}, [animate, animate2, checkedRef])
}, [animate, animate2, innerChecked, itemHeight])

function triggerChange(newChecked: boolean) {
async function triggerChange(newChecked: boolean) {
if (!disabled) {
checkedRef.current = newChecked
setInnerChecked(newChecked)
onChange?.(newChecked)
const result = onChange?.(newChecked)
if (isPromise(result)) {
setInnerLoading(true)
try {
await result
setInnerLoading(false)
} catch (e) {
setInnerLoading(false)
throw e
}
}
return newChecked
}

return innerChecked
}

function onInternalClick() {
const ret = triggerChange(!innerChecked)
async function onInternalClick() {
const ret = await triggerChange(!innerChecked)
// [Legacy] trigger onClick with value
onPress?.(ret)
animate({ toValue: ret ? 1 : 0 })
}

function onPressIn() {
Expand All @@ -134,6 +162,7 @@ const AntmSwitch = ({
})
.split(' ')
.map((a) => styles[a])
.concat([style])

const ant_switch_inner = classNames(`${prefixCls}_inner`, {
[`${prefixCls}_inner_checked`]: innerChecked,
Expand All @@ -149,6 +178,13 @@ const AntmSwitch = ({
})
.split(' ')
.map((a) => styles[a])
.concat([
{
width: itemHeight - BORDER_WIDTH * 2,
height: itemHeight - BORDER_WIDTH * 2,
borderRadius: itemHeight - BORDER_WIDTH * 2,
},
])

// color props
const Color = innerChecked
Expand All @@ -170,51 +206,44 @@ const AntmSwitch = ({
const accessibilityState = {
checked: innerChecked,
disabled,
busy: loading,
busy: innerLoading,
}

return (
<View
<Pressable
accessibilityRole="switch"
accessibilityState={accessibilityState}
style={[styles[prefixCls], { padding: 1 }]}>
<ButtonWave
{...restProps}
Color={Color}
disabled={disabled}
onPressIn={onPressIn}
onPressOut={onPressOut}
onPress={onInternalClick}>
<View
style={[
ant_switch,
Boolean(trackColor || color) && SwitchTrackColor,
]}>
<Animated.View
style={[
ant_switch_handle,
SwitchThumbColor,
transitionWidth,
]}>
{loading && (
<RNActivityIndicator
color={Color}
size={18}
styles={{
spinner: {
padding: 2,
opacity: 0.4,
},
}}
/>
)}
</Animated.View>
<AnimatedView style={[ant_switch_inner, transitionMargin]}>
{innerChecked ? checkedChildren : unCheckedChildren}
</AnimatedView>
</View>
</ButtonWave>
</View>
{...restProps}
disabled={disabled}
onPressIn={onPressIn}
onPressOut={onPressOut}
onPress={onInternalClick}>
<View
style={[
ant_switch,
Boolean(trackColor || color) && SwitchTrackColor,
{ minWidth: itemHeight + PADDING },
]}
onLayout={wrapperMeasure}>
<Animated.View
style={[ant_switch_handle, SwitchThumbColor, transitionWidth]}>
{innerLoading && (
<RNActivityIndicator
color={Color}
size={18}
styles={{
spinner: {
opacity: 0.4,
},
}}
/>
)}
</Animated.View>
<AnimatedView style={[ant_switch_inner, transitionMargin]}>
{innerChecked ? checkedChildren : unCheckedChildren}
</AnimatedView>
</View>
</Pressable>
)
}}
</WithTheme>
Expand Down
Loading

0 comments on commit 3c67bb2

Please sign in to comment.