0%

在做 SideProject 時很常遇到一個狀況
TextInput 在 Focus 狀態時,是沒辦法立刻觸發其他按鈕的
你必須先點擊螢幕一次 (TextInput和鍵盤以外的地方),將鍵盤收起
才能觸發別的按鈕
其實不只是按鈕,其他需要 recieve touch 的元件也有這個問題

至於為什麼強調是在做 sideProject 的時候呢?
因為這在一般情況不太算是問題
直到我以使用者的角度來操作app時才發現
當我預期點擊按鈕有用時,結果居然只是給我收起鍵盤而已?
真的很煩躁啊!我的耐心都到哪去了
這時我才了解使用者經驗的精隨
任何操作都要有預期的效果
只要有任何一次的點擊失效
負評的心情就來了

回到問題本身,會有這個限制我想是因為輸入文字跟顯示鍵盤這兩件事是密切相關的
該輸入文字的時候就該顯示鍵盤
不打算輸入文字的時候就關閉鍵盤
在流程操作上比較直觀
但就像我說的,這不符合使用者的直覺
那該怎麼控制呢?

ScrollView 來幫個忙


ScrollView 有一個屬性是我們需要的
叫做 keyboardShouldPersistTaps
一句話解釋:鍵盤打開的時候要不要接收觸控訊號?

可以設定三種值:
'never': 當子元件的 TextInput focus 時,點擊文字框和鍵盤以外的地方會關閉鍵盤,當這件事發生時,子元件不會接收到 tap
'always': 鍵盤不會自動收起,ScrollView 不會接收到 tap (但可以滑動),子元件可以接收到 tap
'handled': 如果 tap 被子元件或父元件 catch,鍵盤不會自動收起;但如果是被 ScrollView catch,鍵盤還是會收起來。

所以策略是這樣的
我們可以將此屬性設為'handled',先防止鍵盤自動關閉
這時按鈕就能互動了
接著再手動控制鍵盤關閉的時機就好
控制鍵盤的方式是用 react native 的KeyboardAPI

1
2
3
4
5
6
7
8

import {ScrollView, Keyboard} from 'react native'

<ScrollView keyboardShouldPersistTaps="handled">
<TextInput/>
<TouchableOpacity/>
</ScrollView>

正當我以為沒問題的時候來了個小插曲ˋˊ

檢查上層了嗎?


在某一次我完成這些設定後卻發現沒效果!? 按鈕還是不能按
經過一番研究後終於知道了
設定沒效果代表在此元件之上一定還有另一層沒設定的 ScrollView
所以只要找到該標籤,加上keyboardShouldPersistTaps的屬性就可以了
記得要一路檢查到 root 哦

(2022.08.22 更新) 從 Expo SDK 45 之後 expo-app-loading 已經 deprecated,只能使用 expo-splash-screen

介紹


Expo 提供了兩個 API
讓你能夠修改 app 的讀取畫面
分別是 AppLoadingSplashScreen

AppLoading 是一個元件,當整個app渲染的只有AppLoading而沒有別的元件時
它就會讓 splash screen 持續顯示
所以我們就可以在 app 剛打開正在進行 API call 時先渲染 AppLoading
等資料都得到了再恢復顯示主畫面

另一個 SplashScreen 就不是元件了
但他提供兩個函數

  1. SplashScreen.preventAutoHideAsyce():防止 splash screen 在程式讀取完後關閉
  2. SplashScreen.hideAsyce(): 觸發時,關閉 spalsh screen

簡單來說他讓我們能自由控制 splash screen 消失的時機
除了可以達到跟單獨使用 AppLoading 一樣的效果外
更厲害的是我們也能藉此讓App初始化完成後
由顯示靜態 splash screen 切換成動畫(看你要放 gif 或寫 animation 都可以)

使用方式


首先下載並引入

1
2
expo install expo-app-loading
expo install expo-splash-screen
1
2
import AppLoading from 'expo-app-loading';
import * as SplashScreen from 'expo-splash-screen';

再來,若你只想單獨使用 AppLoading 可以這樣做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const [isLoading, setIsLoading] = useState(true)

const fetchData = async () => {
try{
await fetch('...')
} catch(e){
console.log(e)
}finally{
setIsLoading(false)
}
}

useEffect(()=>{
fetchData()
},[])

if (isLoading) {
return <AppLoading/>
} else return <App/>

其實這可以應付大部分的狀況
例如在進入app前先讀取字型等等的
然而使用 SplashScreen 的話靈活度更高

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 const [isLoading, setIsLoading] = useState(true)

const fetchData = async () => {
try{
await SplashScreen.preventAutoHideAsync() //等等!先別關!
await fetch('...') // 做一些事
} catch(e){
console.log(e)
}finally{
SplashScreen.preventAutoHideAsync() //好可以關了
}
}

useEffect(()=>{
fetchData()
},[])

if (isLoading) {
return null //不渲染東西 但因為 splash screen 還蓋在畫面上,所以不會沒畫面
} else return <App/>

(2022.08.22 更新) 從 Expo SDK 45 之後 expo-app-loading 已經 deprecated,只能使用 expo-splash-screen

這篇會示範如何實作購物車app常有的全選功能

之前曾經用錯誤的方式做導致卡住
而且腦筋轉不過來
一時想不到其他變通方式
只好到社團問
一問才發現其實很容易
不過應該很多事情都是這樣啦
不懂的時候就好像很難
會了之後就覺得自己好蠢XD

首先做出選單
假設 MOCK 是我們資料庫的資料,或是從api獲取的資料
data state 是我們的資料變數
之所以先把資料放在 react state 中
是因為傳入 FlatList 的資料必須是能夠變動的
這樣我們只要改變資料
就能動態渲染在FlatList中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

const MOCK = [
{ name: 'Andy' },
{ name: 'Beck' },
{ name: 'Carl' },
]

const RenderData = ({ item }) => {

return (
<TouchableOpacity
onPress={() => { }}
style={[styles.item_btn]}>
<Text>{item.name}</Text>
</TouchableOpacity>
)
}

export default function App() {
return (
<View style={styles.container}>

<View>
<TouchableOpacity
onPress={()=>{}}
style={[styles.selectAll_btn]}>
<Text>全選</Text>
</TouchableOpacity>
</View>

<View style={styles.flatlist_container}>
<FlatList
data={MOCK}
renderItem={(cases) => <RenderData item={cases.item} />}
keyExtractor={(cases, index) => index.toString()}
style={{ width: '100%', }}
/>
</View>

</View >
);
}

左圖是 app 剛啟動的畫面
中間圖是選取單個項目時的畫面
右圖是點擊全選按鈕時的畫面

以下是實作全選功能的流程圖

虛線部分是 app 剛啟動時的狀態,這時data也還是初始值
接下來我會宣告一個selectedItem陣列來放我所選的項目物件

1
const [selectedItems, setSelectedItems] = useState([]);

每當項目被點擊時,用some()檢查
如果selectedItem中有這個物件,就用filter()把那個項目從中清除;
反之如果沒有的話,就複製一份丟進去
這個函數會被丟進每個項目中

1
2
3
4
5
6
const onItemSelect = (item) => {
setSelectedItems(
prev => prev.some((prevItem) => prevItem === item)
? prev.filter((prevItem) => prevItem !== item)
: [...prev, item])
}

接著記得把selectedItem傳入各個項目中
以便讓每個項目判斷自己是否在其中,改變isSelected state,藉此渲染自己的樣式

1
2
3
4
5
6
7
// in <RenderData/>
const [isSelected, setIsSelected] = useState(false)
useEffect(() => {
if (selectedItems.some(selectedItem => selectedItem === item)) {
setIsSelected(true)
} else { setIsSelected(false) }
}, [selectedItems])

選取後可能還會編輯項目,這時只要更新data並清空selectedItem就ok了
我這邊就先不做編輯的部分

接著回到app()
別忘了還沒做全選按鈕的功能
策略很簡單,宣告一個isAllSelected state
如果還沒全選,就把data中的所有項目丟進selectedItems
如果已經全選了就把selectedItems清空
我們就叫他selectAll

1
2
3
4
5
const [isAllSelected, setIsAllSelected] = useState(false); //現在有沒有全選?
const selectAll = () => {
if (isAllSelected) setSelectedItems([])
else setSelectedItems([...MOCK])
}

在useEffect中判斷有沒有全選

1
2
3
4
5
useEffect(() => {
if (selectedItems.length === MOCK.length) {
setIsAllSelected(true)
} else { setIsAllSelected(false) }
}, [selectedItems]) // 每當selectedItems改變,就檢查一次

把前面提到的onItemSelect和剛剛的selectAll畫成流程圖:

最後的成果:

完整程式碼放在這邊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

import React, { useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';

const MOCK = [
{ name: 'Andy' },
{ name: 'Beck' },
{ name: 'Carl' },
]

const RenderData = ({ item, selectedItems, onItemSelect }) => {

const [isSelected, setIsSelected] = useState(false);
useEffect(() => {

if (selectedItems.some(selectedItem => selectedItem === item)) {
setIsSelected(true)
} else { setIsSelected(false) }
}, [selectedItems])

return (
<TouchableOpacity
onPress={() => { onItemSelect() }}
style={[styles.item_btn, { backgroundColor: isSelected ? 'gray' : 'transparent' }]}>
<Text>{item.name}</Text>
</TouchableOpacity>
)
}

export default function App() {
const [selectedItems, setSelectedItems] = useState([]);
const [isAllSelected, setIsAllSelected] = useState(false);
const onItemSelect = (item) => {
setSelectedItems(prev => prev.some((prevItem) => prevItem === item) ? prev.filter((prevItem) => prevItem !== item) : [...prev, item])
}
const selectAll = () => {
if (isAllSelected) {
setSelectedItems([])
} else {
setSelectedItems([...MOCK])
}
}


useEffect(() => {
if (selectedItems.length === MOCK.length) {
setIsAllSelected(true)
} else { setIsAllSelected(false) }
}, [selectedItems])

return (
<View style={styles.container}>

<View>
<TouchableOpacity
onPress={selectAll}
style={[styles.selectAll_btn, { backgroundColor: isAllSelected ? 'gray' : 'transparent' }]}>
<Text>全選</Text>
</TouchableOpacity>
</View>

<View style={styles.flatlist_container}>
<FlatList
data={MOCK}
renderItem={(cases) => <RenderData item={cases.item} selectedItems={selectedItems} onItemSelect={() => { onItemSelect(cases.item) }} />}
keyExtractor={(cases, index) => index.toString()}
style={{ width: '100%', }}
/>
</View>

</View >
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
selectAll_btn: {
borderWidth: 1,
width: 80,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
flatlist_container: {
borderWidth: 1,
width: 200,
height: 150,
alignItems: 'center',
marginTop: 20,
},
item_btn: {
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
});