프로젝트/할일목록 앱 (RN)

2. 홈화면 구현하기 - 할일목록 보여주기

syleemomo 2023. 8. 20. 17:37
728x90

 

* 오늘 날짜 보여주기

import React from 'react'
import { View, Text, StyleSheet } from 'react-native'

function DateHeader({ date }){
  const year = date.getFullYear()
  const month = date.getMonth() + 1
  const day = date.getDate()
  
  return (
    <View style={styles.container}>
      <Text style={styles.dateText}>{`${year}년 ${month}월 ${day}일`}</Text>
    </View>
  )
}
const styles = StyleSheet.create({
  container: {
    padding: 10,
    backgroundColor: '#a8c8ffff'
  },
  dateText: {
    fontSize: 30,
    color: 'white'
  }
})

export default DateHeader

components > DateHeader.js 파일을 생성하고 위와 같이 작성한다. 부모 컴포넌트에서 date 속성을 props 로 전달받아 년, 월, 일을 추출하고 화면에 날짜를 표시한다. 

import React from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar } from 'react-native'

import DateHeader from '../components/DateHeader' // 추가

function HomeScreen({navigation}){
  const date = new Date()
  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      <View>
        <Text>할일목록</Text>
      </View>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1
  }
})
export default HomeScreen

 screens > HomeScreen.js 파일을 위와 같이 수정한다. DateHeader 컴포넌트를 가져와서 렌더링한다. DateHeader 컴포넌트의 date 속성에는 오늘 날짜를 전달해준다. 

 

* 할일추가 입력창 만들기

components > TodoInsert.js 파일을 생성하고 아래와 같이 작성한다. TextInput 컴포넌트를 이용하여 사용자 입력을 받는다. TextInput 컴포넌트의 value 속성은 입력창에 보여지는 값이다. todoText 를 value 속성에 지정해주면 todoText 값이 변경될때 value 속성값도 함께 업데이트되면서 입력창의 값이 업데이트된다.

import React, { useState } from 'react'
import { View, TextInput, StyleSheet } from 'react-native'

function TodoInsert(){
  const [todoText, setTodoText] = useState('')
  console.log(todoText)
  return (
    <View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={styles.input}
        value={todoText}
        onChangeText={setTodoText}/>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    height: 50,
    paddingLeft: 20,
    borderColor: 'transparent',
    borderTopWidth: 1,
    justifyContent: 'center',
  },
  input: {
    fontSize: 20,
    color: '#a8c8ffff',
    paddingVertical: 10,
  }
})

export default TodoInsert

onChangeText 속성은 입력창에 글자를 작성할때마다 실행된다. 결국 사용자가 입력창에 글자를 작성할때마다 setTodoText 함수가 실행되면서 아래와 같이 콘솔창에 내가 작성한 글자가 보이고, todoText 값이 변경된다. todoText 값이 변경되면, 입력창에 보여지는 글자도 업데이트된다. 이를 리액티브라고 한다. 즉, 변수값과 화면에 보이는 값이 서로 연결되어 있다. 

입력창에 글씨를 입력할때 콘솔에 출력되는 문자열

import React from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar } from 'react-native'

import DateHeader from '../components/DateHeader'
import TodoInsert from '../components/TodoInsert' // 추가

function HomeScreen({navigation}){
  const date = new Date()
  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      <View>
        <Text>할일목록</Text>
      </View>
      <TodoInsert/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  }
})
export default HomeScreen

screens > HomeScreen.js 파일을 위와 같이 수정한다. TodoInsert 컴포넌트를 가져와서 렌더링한다. 

 

* 할일추가 버튼 만들기

import React, { useState } from 'react'
import { 
    View, 
    Text, // 추가
    TextInput, 
    TouchableOpacity, // 추가
    StyleSheet, 
    Keyboard // 추가
} from 'react-native'

function TodoInsert(){
  const [todoText, setTodoText] = useState('')
  const onPress = () => {
    setTodoText('') // 입력창 초기화
    Keyboard.dismiss() // 키보드 감추기
  }
  console.log(todoText)
  return (
    <View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={styles.input}
        value={todoText}
        onChangeText={setTodoText} // 입력창에 글자를 입력할때
        onSubmitEditing={onPress} // 엔터키 눌렀을때
        />
        <TouchableOpacity 
            activeOpacity={0.7} // 버튼 클릭시 투명도 변경
            onPress={onPress}   // 버튼 클릭시 실행
        > 
            <View style={styles.button}>
                <Text style={styles.buttonText}>추가</Text>
            </View>
        </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    height: 70, // 수정
    paddingLeft: 10,
    borderColor: 'transparent',
    borderTopWidth: 3, // 수정
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff',
  },
  input: {
    color: '#a8c8ffff',
    fontSize: 20,
    paddingVertical: 20, // 수정
  },
  button: {
    backgroundColor: '#a8c8ffff',
    width: 80,
    height: 35,
    borderRadius: 20,
    marginRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden'
  },
  buttonText: {
    color: '#fff',
    letterSpacing: 3,
    fontWeight: 'bold',
    fontSize: 15
  }
})

export default TodoInsert

components > TodoInsert.js 파일을 위와 같이 수정한다. 할일추가 버튼을 만들어서 사용자가 할일을 추가할 수 있도록 한다. 일반적인 버튼은 Button 컴포넌트를 사용할 수 있지만, 디자인을 커스터마이징하기 힘들다. 그렇기 때문에 버튼 디자인을 커스터마이징하기 위하여 View 컴포넌트를 TouchableOpacity 컴포넌트로 감싸주는 방식으로 버튼을 구현한다. 할일추가 버튼을 클릭하면 onpress 핸들러 함수가 실행되면서 입력창을 초기화하고, 가상 키보드를 화면에서 숨긴다. onSubmitEditing 속성은 입력창에서 엔터키를 눌렀을때 실행된다. 

 

https://yuddomack.tistory.com/entry/5React-Native-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EB%94%94%EC%9E%90%EC%9D%B8-4%EB%B6%80-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%99%80-UI-%EB%A7%88%EB%AC%B4%EB%A6%AC

 

5.React Native 레이아웃 디자인 - 4부 이미지 컴포넌트와 UI 마무리

이 글은 5.React Native 레이아웃 디자인 - 1부 flex와 width, height 5.React Native 레이아웃 디자인 - 2부 배치(Flex Direction)와 정렬(justify content, align items) 5.React Native 레이아웃 디자인 - 3부 커스텀 버튼 5.React

yuddomack.tistory.com

 

* 할일목록이 비어있는 경우 화면 구현하기

import React from 'react'
import { View, Text, Image, StyleSheet } from 'react-native'

function Default(){
    return (
        <View style={styles.container}>
            <Image source={require('../assets/imgs/todo.png')}/>
            <Text style={styles.guideText}>현재 할일목록이 비어있습니다.</Text>
        </View>
    )
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#fff'
    },
    guideText: {
        fontSize: 20,
        marginTop: 30
    }
}) 

export default Default

components > Default.js 파일을 생성하고 위와 같이 작성한다. Image 컴포넌트를 사용하여 화면이 비어있는 경우 사용자에게 안내를 해준다. 이미지 파일의 경로는 require 함수를 이용하여 불러온다. components 폴더와 동일한 위치에 assets > imgs 폴더를 생성하고 앱에서 사용할 이미지를 저장해둔다. 할일목록은 StatusBar, DateHeader, TodoInser 컴포넌트를 제외한 나머지 영역을 가득채워야 하므로 flex: 1을 적용한다.

import React from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar } from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'

function HomeScreen({navigation}){
  const date = new Date()
  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      <Default/>
      <TodoInsert/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  }
})
export default HomeScreen

screens > HomeScreen.js 파일을 위와 같이 수정한다. Default 컴포넌트를 가져와서 렌더링한다. flex:1 을 적용하여 HomeScreen 컴포넌트의 높이를 모바일 화면에 꽉차게 설정한다. 

 

* 할일목록이 존재하는 경우 화면 구현하기

components > TodoList.js 파일을 생성하고 아래와 같이 작성한다. TodoList 컴포넌트는 todos 라는 props 데이터를 부모 컴포넌트로부터 전달받는다. 해당하는 배열 데이터를 FlatList 컴포넌트의 data 속성에 전달해주면 renderItem 함수가 실행되면서 FlatList 컴포넌트 내부에서 배열 원소를 하나씩 꺼낸 다음에 아이템 각각에 대한 뷰 화면으로 렌더링해준다.

import React from 'react'
import { FlatList, View, Text, StyleSheet } from 'react-native'

function TodoList({ todos }){
    return (
        <FlatList
            data={todos}
            style={styles.container}
            keyExtractor={item => item.id.toString()}
            ItemSeparatorComponent={() => <View style={styles.line}/>}
            renderItem={({item}) => (
            	// 아이템 각각의 뷰 화면
                <View style={styles.item}> 
                    <View style={styles.titleMargin}>
                        <Text style={styles.title}>{item.title}</Text>
                    </View>
                    <View>
                        <Text>{item.category}</Text>
                        <Text style={styles.dateText}>{item.createdAt}</Text>
                    </View>
                </View>
            )}
        />
    )
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: '#fff',
    },
    line: {
        backgroundColor: '#ddd',
        height: 1,
    },
    item: {
        flexDirection: 'row',
        alignItems: 'flex-start',
        paddingLeft: 10,
        paddingVertical: 10,
        // backgroundColor: '#d6e3ffff',
        // borderBottomWidth: 1,
        // borderBottomColor: '#a8c8ffff',
    },
    titleMargin: {
        marginRight: 10
    },
    title: {
        fontWeight: 'bold',
        fontSize: 20,
    },
    dateText: {
        fontSize: 12
    }
})

export default TodoList

keyExtractor 속성은 리액트의 key 와 동일한 역할을 한다. 리스트에서 각 항목의 순서를 설정해준다. 해당 속성을 설정하지 않으면 배열에 변경사항이 있는경우 해당 아이템을 찾는데 시간이 오래걸린다. ItemSeparatorComponent 속성은 각 아이템 사이에 구분선을 추가하거나 구분해주는 뷰가 필요할때 사용한다.

import React, { useState } from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar } from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'

function HomeScreen({navigation}){
  const date = new Date()
  const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])
  
  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos}/>}
      <TodoInsert/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  }
})
export default HomeScreen

screens > HomeScreen.js 파일을 위와 같이 수정한다.

import React, { useState } from 'react'

state 값을 사용하기 위하여 useState 함수를 임포트한다.

import TodoList from '../components/TodoList'

할일목록을 화면에 보여주기 위하여 TodoList 컴포넌트를 임포트한다.

const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])

useState 함수를 이용하여 할일목록 초기값을 설정한다.

{todos.length === 0 ? <Default/> : <TodoList todos={todos}/>}

할일목록 배열에 할일이 하나도 존재하지 않으면 Default 컴포넌트를 보여주고, 할일이 존재하면 TodoList 컴포넌트를 이용하여 화면에 할일목록을 보여준다.

 

* 코드 리팩토링하기

import React from 'react'
import { View, Text, StyleSheet } from 'react-native'

function TodoItem({ item }){
    return (
        <View style={styles.item}>
            <View style={styles.titleMargin}>
                <Text style={styles.title}>{item.title}</Text>
            </View>
            <View>
                <Text>{item.category}</Text>
                <Text style={styles.dateText}>{item.createdAt}</Text>
            </View>
        </View>
    )
}

const styles = StyleSheet.create({
    item: {
        flexDirection: 'row',
        alignItems: 'flex-start',
        paddingLeft: 10,
        paddingVertical: 10,
        // backgroundColor: '#d6e3ffff',
        // borderBottomWidth: 1,
        // borderBottomColor: '#a8c8ffff',
    },
    titleMargin: {
        marginRight: 10
    },
    title: {
        fontWeight: 'bold',
        fontSize: 20,
    },
    dateText: {
        fontSize: 12
    }
})

export default TodoItem

components > TodoItem.js 파일을 생성하고 위와 같이 작성한다. TodoItem 컴포넌트에서 렌더링하는 반환값은 원래 TodoList 컴포넌트의 renderItem 함수의 반환값이었다. 즉, renderItem 함수의 반환값을 TodoItem 컴포넌트로 따로 뺀 것이다. 

 

 

* 한글키보드 설정하기

https://suyou.tistory.com/174

 

안드로이드 에뮬레이터 한글 키보드 실행

안드로이드 에뮬레이터를 처음 실행한 경우 키보드에 한글이 표시되지 않는다. 키보드에 영어만 표시되고 한글로 전환하는 지구모양의 아이콘이 표시되지 않는다. 영문,한글 전환 아이콘이 보

suyou.tistory.com

 

구글 검색창에서 settings 검색하기
settings 아이콘 클릭하기
language 검색하기
Add a language 클릭하기
KOR 로 검색하기
한국어 선택하기
대한민국 선택하기
한국어 우선순위 올리기
한국어 우선순위 올리기 2
한국어 우선순위 높아진 모습
한글 키보드

 

https://stackoverflow.com/questions/67098132/how-to-call-function-on-enter-press-in-textinput-react-native

 

How to call function on 'Enter' press in TextInput - React Native

I am trying to make Search Bar. I want user to type something in TextInput and when user press 'Enter' I want to call my function. <TextInput //here when user press enter call function() style={...

stackoverflow.com

 

* onSubmitEdting 에서 엔터키 두번 눌러야 동작하는 문제 해결하기

TextInput 컴포넌트에 onSubmitEditing 과 onChangeText 속성이 함께 존재하는 경우 첫번째 엔터키에서는 onChangeText 가 동작하고 두번째 엔터키에서는 onSubmitEditing 이 동작한다. 이렇게 되면 엔터키 두번 눌러야 할일이 추가된다. 그래서 onSubmitEditing 과 onChangeText 를 같이 사용하지 말고 차라리 onChangeText 에서 엔터키 누른경우와 안 누른경우를 구분해서 엔터키 누른 경우에 할일이 추가되도록 한다. 

 

* 할일목록에 할일 추가시 에러 처리하기

import React from 'react'
import { 
    View, 
    Text,
    TextInput, 
    TouchableOpacity, 
    StyleSheet, 
    Keyboard
} from 'react-native'

function TodoInsert({ onInsertTodo, todoText, setTodoText }){
  const onPress = () => {
    const trimedText = todoText.trim()
    onInsertTodo(trimedText)
  }
  const handleChange = (text) => { 
    if (/\n/.test(text)) { // 엔터키 입력시 
      onPress() // 할일추가
    } else {
      setTodoText(text)
    }
  }
  console.log(todoText)
  return (
    <View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={styles.input}
        value={todoText}
        blurOnSubmit={ false } // 탭키 누를때 키보드 사라지지 않게 하기
        onChangeText={handleChange} // 입력창에 글자를 입력할때
        returnKeyType="done" // 엔터키 아이콘 변경
        maxLength={50} // 최대 글자수 제한
        autoCorrect={false} // 자동완성기능 끄기
        // onSubmitEditing={onPress} // 여기서 하면 엔터키 두번 눌러야 할일추가됨
        />
        <TouchableOpacity 
            activeOpacity={0.7} // 버튼 클릭시 투명도 변경
            onPress={onPress}   // 버튼 클릭시 실행
        > 
            <View style={styles.button}> 
                <Text style={styles.buttonText}>추가</Text>
            </View>
        </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    height: 70,
    paddingLeft: 10,
    borderColor: 'transparent',
    borderTopWidth: 3,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff',
  },
  input: {
    color: '#a8c8ffff',
    fontSize: 20,
    paddingVertical: 20,
    flex: 1
  },
  button: {
    backgroundColor: '#a8c8ffff',
    width: 80,
    height: 35,
    borderRadius: 20,
    marginRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden'
  },
  buttonText: {
    color: '#fff',
    letterSpacing: 3,
    fontWeight: 'bold',
    fontSize: 15
  }
})

export default TodoInsert

 

components > TodoInsert.js 파일을 위와 같이 수정한다. 

import React, { useState } from 'react'

부모에서 state 를 변경한 다음 TodoInsert 컴포넌트의 props 로 전달할 것이므로 useState 를 이용하여 더이상 해당 컴포넌트 내부에서 state 를 변경할 필요가 없다.  그래서 아래와 같이 useState 는 임포트에서 제외한다.

import React from 'react'

 

function TodoInsert(){
}

부모로부터 변경된 state 값을 props 로 전달받을 것이므로 필요한 props 를 아래와 같이 추가해준다.

function TodoInsert({ onInsertTodo, todoText, setTodoText }){
}

아래 코드는 더이상 필요하지 않으므로 삭제한다. todoText 와 setTodoText 는 부모 컴포넌트(HomeScreen)에서 정의하고 사용될 것이기 때문이다.

const [todoText, setTodoText] = useState('')

 

const onPress = () => {
    setTodoText('') // 입력창 초기화
    Keyboard.dismiss() // 키보드 감추기
}

사용자가 할일을 입력하고 추가 버튼을 클릭하거나 입력창에서 엔터키를 누를때 실행되는 이벤트핸들러 함수이다. 기존에는 입력창을 초기화하고 키보드를 감추는 기능만 필요했지만, 지금은 사용자가 추가하는 할일의 최소 글자수와 할일제목에 대한 중복체크 기능도 만들어야 하기 때문에 아래와 같이 수정한다.

const onPress = () => {
    const trimedText = todoText.trim()
    onInsertTodo(trimedText)
}

사용자는 할일을 추가할때 스페이스바만 누르고 추가할수도 있으므로 trim() 함수를 이용하여 공백을 제거하고 onInsertTodo 함수를 실행한다. 이때 함수의 인자로 사용자가 입력한 문자열을 전달한다. onInsertTodo 는 부모로부터 전달받은 props 이며, 해당 함수에서 대부분의 기능을 수행한다. 

const handleChange = (text) => { 
    if (/\n/.test(text)) { // 엔터키 입력시 
      onPress() // 할일추가
    } else {
      setTodoText(text)
    }
  }

해당 코드를 추가한다. TextInput 컴포넌트의 onChangeText 속성에 전달되는 이벤트핸들러 함수이다. 사용자가 입력창에 글자를 입력할때마다 실행된다. 이때 사용자가 글작성을 마치고 할일을 추가하는지 아니면 현재 작성중인지를 판단하기 위하여 정규표현식을 이용하여 엔터키 입력여부를 체크한다. 만약 사용자가 할일을 입력중이면 setTodoText(text) 를 실행하여 입력창에 표시되는 글자를 변경해주고, 작성을 마치고 엔터키를 누르면 onPress 함수를 실행하여 할일을 추가한다.

<View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={styles.input}
        value={todoText}
        onChangeText={setTodoText} // 입력창에 글자를 입력할때
        onSubmitEditing={onPress} // 엔터키 눌렀을때
        />
        <TouchableOpacity 
            activeOpacity={0.7} // 버튼 클릭시 투명도 변경
            onPress={onPress}   // 버튼 클릭시 실행
        > 
            <View style={styles.button}>
                <Text style={styles.buttonText}>추가</Text>
            </View>
        </TouchableOpacity>
    </View>

입력창과 추가버튼에 대한 뷰이다. 위에서도 언급했듯이 onChangeText 와 onSubmitEditing 속성을 동시에 사용하면 추가 버튼을 두번 클릭해야 실제로 할일이 추가되기 때문에 아래와 같이 onChangeText 속성만 사용하여 사용자 입력을 처리하였다. 

<View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={styles.input}
        value={todoText}
        blurOnSubmit={ false } // 탭키 누를때 키보드 사라지지 않게 하기
        onChangeText={handleChange} // 입력창에 글자를 입력할때
        returnKeyType="done" // 엔터키 아이콘 변경
        maxLength={50} // 최대 글자수 제한
        autoCorrect={false} // 자동완성기능 끄기
        // onSubmitEditing={onPress} // 여기서 하면 엔터키 두번 눌러야 할일추가됨
        />
        <TouchableOpacity 
            activeOpacity={0.7} // 버튼 클릭시 투명도 변경
            onPress={onPress}   // 버튼 클릭시 실행
        > 
            <View style={styles.button}> 
                <Text style={styles.buttonText}>추가</Text>
            </View>
        </TouchableOpacity>
    </View>

blurOnSubmit 속성은 입력해야 하는 필드가 여러개인 경우에 탭키를 누를때마다 키보드가 사라지고 나타나는 플리커(fliker) 현상을 방지하기 위한 기능이다. onChnageText 속성에는 handleChange 함수를 연결하여 사용자가 입력창에 뭔가 입력할때마다 실행되도록 한다. returnKeyType 은 엔터키 뷰(ui)를 설정한다. "done"으로 설정하면 안드로이드에서는 체크 표시가 되고 ios 에서는 "완료"나 "done"으로 표시된다. "search"로 설정하면 검색을 위한 돋보기 아이콘이 표시된다. autoCorrect 는 자동완성기능의 적용여부를 설정한다. true 로 설정하면 사용자가 입력한 문자열을 자동완성으로 바꾸는 경우도 있기 때문에 끄기로 한다. 

const styles = StyleSheet.create({
  container: {
    height: 70,
    paddingLeft: 10,
    borderColor: 'transparent',
    borderTopWidth: 3,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff',
  },
  input: {
    color: '#a8c8ffff',
    fontSize: 20,
    paddingVertical: 20,
    flex: 1 // 설정하지 않으면 입력창에 입력한 글자가 길어지면 추가 버튼이 밀려남
  },
  button: {
    backgroundColor: '#a8c8ffff',
    width: 80,
    height: 35,
    borderRadius: 20,
    marginRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden'
  },
  buttonText: {
    color: '#fff',
    letterSpacing: 3,
    fontWeight: 'bold',
    fontSize: 15
  }
})

스타일 설정에서 input (입력창)에 flex: 1 을 설정하여 추가 버튼을 제외한 나머지 영역을 차지할 수 있도록 한다. 

 

import React, { useState } from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar, Keyboard } from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'

function HomeScreen({navigation}){
  const date = new Date()
  const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])
  const [todoText, setTodoText] = useState('')
  const onInsertTodo = (trimedText) => {
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: todoContents[1] || '자기계발', //
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
      }else{
        setTodos([...todos, newTodo])
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
    }
  }

  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
      <TodoInsert onInsertTodo={onInsertTodo} todoText={todoText} setTodoText={setTodoText}/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  }
})
export default HomeScreen

components > HomeScreen.js 파일을 위와 같이 수정한다. 

import { SafeAreaView, View, Text, StyleSheet, StatusBar } from 'react-native'

키보드를 제어할 것이므로 Keyboard 컴포넌트를 임포트한다.

import { SafeAreaView, View, Text, StyleSheet, StatusBar, Keyboard } from 'react-native'

입력창인 TodoInsert 컴포넌트의 상태(state)는 부모 컴포넌트인 HomeScreen 에서 변경할 것이므로 아래와 같이 state 를 초기화한다.

const [todoText, setTodoText] = useState('')

onInsertTodo 는 사용자가 할일을 작성하고 엔터키를 누르거나 추가 버튼을 클릭할때 실행되는 함수이다. 해당 함수는 HomeScreen 컴포넌트에 존재하는 todos 상태를 이용하므로 HomeScreen 컴포넌트에서 정의한 다음에 TodoInsert 컴포넌트의 props 로 전달해준다. 왜냐하면 해당 함수의 사용은 TodoInsert 컴포넌트에서 하기 때문이다. 단지 todos 상태에 접근하기 위하여 HomeScreen 컴포넌트에서 정의한 것이다. 결국 변수 스코프 문제이다.

const onInsertTodo = (trimedText) => {
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: todoContents[1] || '자기계발', //
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
      }else{
        setTodos([...todos, newTodo])
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
    }
  }

사용자가 입력한 글자가 3자 이하이면 입력창에 3자 이상 입력하라고 안내해준다. 사용자가 입력한 글자가 3자를 초과하면 현재 todos 배열 길이에 1 을 더해서 현재 추가하려는 할일에 대한 고유 ID 값을 생성한다.  사용자가 입력한 문자열을 콤마(,)로 구분해서 할일제목과 카테고리를 추출한다. new Date() 을 이용하여 할일이 생성된 시각을 저장한다. category 를 입력하지 않으면 디폴트 값으로 '자기계발' 로 표시한다. 생성시각은 년, 월, 일을 추출하여 문자열 포맷으로 변경하고 저장한다.

배열의 filter 메서드를 이용하여 추가하려는 할일제목이 이미 todos 배열에 존재하는지 검사한다. 즉, 중복체크를 수행한다. 이미 할일제목이 존재하면 입력창에 중복이라고 안내해준다. 추가하려는 할일제목이 todos 배열에 존재하지 않으면 해당 할일을 todos 배열에 추가하고, 입력창을 초기화한다. 그리고 키보드도 화면에서 숨긴다. 

 

<SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos}/>}
      <TodoInsert/>
</SafeAreaView>

기존에는 위와 같이 TodoInsert 컴포넌트에 아무 속성도 지정하지 않았지만 현재는 아래와 같이 부모에서 변경된 state 값을 TodoInsert 컴포넌트에 props 로 전달해준다. 

<SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
      <TodoInsert onInsertTodo={onInsertTodo} todoText={todoText} setTodoText={setTodoText}/>
</SafeAreaView>

onInsertTodo 함수는 사용자가 입력을 마치고 엔터키를 누르거나 추가버튼을 클릭할때 실행된다. todoText 는 입력창에 보여지는 글자이다. setTodoText 는 사용자가 글자를 입력할때 입력창 화면의 글자를 변경하기 위함이다. 

 

*  조건에 맞지 않는 할일 작성시 에러 메세지 출력하기 

https://javascript.plainenglish.io/how-to-add-dynamic-styles-in-react-and-react-native-628280320ca4

 

How to Add Dynamic Styles in React and React Native

A beginner’s guide to making your app dynamic

javascript.plainenglish.io

 

import React, { useState } from 'react'
import { SafeAreaView, View, Text, StyleSheet, StatusBar, Keyboard } from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'

function HomeScreen({navigation}){
  const date = new Date()
  const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])
  const [todoText, setTodoText] = useState('')
  const [warning, setWarning] = useState(false)

  const onInsertTodo = (trimedText) => {
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: todoContents[1] || '자기계발', //
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        setTodos([...todos, newTodo])
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
      setWarning(true)
    }
  }

  return (
    <SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
      <TodoInsert 
        onInsertTodo={onInsertTodo} 
        todoText={todoText} 
        setTodoText={setTodoText} 
        warning={warning} 
        setWarning={setWarning}/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  }
})
export default HomeScreen

components > HomeScreen.js 파일을 위와 같이 수정한다. 

const [warning, setWarning] = useState(false)

경고 메세지를 보여주기 위하여 warning 상태를 추가한다.

if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: todoContents[1] || '자기계발', //
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true) // 추가
      }else{
        setTodos([...todos, newTodo])
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
      setWarning(true) // 추가
    }

사용자가 작성한 할일이 3자 이하이거나 이미 할일목록에 존재하면 경고 메세지를 보여주기 위하여 setWarning 함수를 이용하여 warning 상태를 true 로 변경해준다. 

<SafeAreaView style={styles.block}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <DateHeader date={date}/>
      {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
      <TodoInsert 
        onInsertTodo={onInsertTodo} 
        todoText={todoText} 
        setTodoText={setTodoText} 
        warning={warning}         // 추가
        setWarning={setWarning}/> // 추가
</SafeAreaView>

현재의 warning 상태를 TodoInsert 컴포넌트 내부에서 조회하기 위하여 props 로 전달해준다. setWarning 함수도 해당 컴포넌트 내부에서 사용할 것이므로 함께 props 로 전달해준다. 

import React from 'react'
import { 
    View, 
    Text,
    TextInput, 
    TouchableOpacity, 
    StyleSheet, 
    Keyboard
} from 'react-native'

function TodoInsert({ onInsertTodo, todoText, setTodoText, warning, setWarning }){
  const onPress = () => {
    const trimedText = todoText.trim()
    onInsertTodo(trimedText)
  }
  const handleChange = (text) => { 
    if (/\n/.test(text)) { // 엔터키 입력시 
      onPress() // 할일추가
    } else {
      setTodoText(text)
      setWarning(false)
    }
  }
  const hideKeyboard = () => {
    Keyboard.dismiss()
  }
  console.log(todoText)
  return (
    <View style={styles.container}> 
      <TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={[styles.input, { color: warning ? 'red': '#a8c8ffff' } ]}
        value={todoText}
        blurOnSubmit={ false } // 탭키 누를때 키보드 사라지지 않게 하기
        onChangeText={handleChange} // 입력창에 글자를 입력할때
        returnKeyType="done" // 엔터키 아이콘 변경
        maxLength={50} // 최대 글자수 제한
        autoCorrect={false} // 자동완성기능 끄기
        onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
        />
        <TouchableOpacity 
            activeOpacity={0.7} // 버튼 클릭시 투명도 변경
            onPress={onPress}   // 버튼 클릭시 실행
        > 
            <View style={styles.button}> 
                <Text style={styles.buttonText}>추가</Text>
            </View>
        </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    height: 70,
    paddingLeft: 10,
    borderColor: 'transparent',
    borderTopWidth: 3,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff',
  },
  input: {
    color: '#a8c8ffff',
    fontSize: 20,
    paddingVertical: 20,
    flex: 1
  },
  button: {
    backgroundColor: '#a8c8ffff',
    width: 80,
    height: 35,
    borderRadius: 20,
    marginRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
    overflow: 'hidden'
  },
  buttonText: {
    color: '#fff',
    letterSpacing: 3,
    fontWeight: 'bold',
    fontSize: 15
  }
})

export default TodoInsert

components > TodoInsert.js 파일을 위와 같이 수정한다. 

function TodoInsert({ onInsertTodo, todoText, setTodoText }){
}

기존에는 3개의 props 를 전달했지만 아래와 같이 2개의 props 가 추가되었다. 

function TodoInsert({ onInsertTodo, todoText, setTodoText, warning, setWarning }){
}

경고 메세지를 출력하고 이후에 사용자가 다시 할일을 작성하면 아래와 같이 warning 상태를 초기화한다. 

const handleChange = (text) => { 
    if (/\n/.test(text)) { // 엔터키 입력시 
      onPress() // 할일추가
    } else {
      setTodoText(text)
      setWarning(false) // 추가
    }
  }

사용자가 추가버튼이나 엔터키를 누르지 않고, 작성을 취소하고 싶을때 키보드에서 완료(체크)버튼을 터치한다. 이때 아래 코드를 실행하여 키보드를 화면에서 숨긴다. 

const hideKeyboard = () => {
    Keyboard.dismiss()
  }

리액트 네이티브에서 조건 스타일링을 하려면 아래와 같이 코드를 작성하면 된다. 우선 스타일을 배열로 묶어주고, 조건에 따라 변경할 스타일은 객체 형태로 추가해준다. 현재는 warning 상태가 true 이면, 입력창의 글자색상을 붉은색으로 설정하고, 그렇지 않으면 보라색으로 설정한다. 사용자가 추가버튼이나 엔터키를 누르지 않고, 작성을 취소하고 싶을때 키보드에서 완료(체크)버튼을 터치한다. 이때 onSubmitEditing 이벤트가 발생하고 위에서 정의한 hideKeyboard 함수가 실행되면서 키보드를 숨긴다.

<TextInput 
        placeholder='할일을 작성해주세요!' 
        placeholderTextColor='#a8c8ffff'  // 안내문구 색상
        selectionColor={'#d6e3ffff'}  // 커서색상
        style={[styles.input, { color: warning ? 'red': '#a8c8ffff' } ]}
        value={todoText}
        blurOnSubmit={ false } // 탭키 누를때 키보드 사라지지 않게 하기
        onChangeText={handleChange} // 입력창에 글자를 입력할때
        returnKeyType="done" // 엔터키 아이콘 변경
        maxLength={50} // 최대 글자수 제한
        autoCorrect={false} // 자동완성기능 끄기
        onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
/>

 

할일목록 추가하기 기능 구현
할일을 3자 미만 작성한 경우
할일 3자 미만 입력시 경고창 메세지 출력
동일한 할일을 작성한 경우
중복된 할일을 작성한 경우 경고창 메세지 출력

 

* 홈화면 헤더에 카테고리 드롭다운 메뉴 만들기 

import React, { useState } from 'react'
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialIcons'

import HomeScreen from './screens/HomeScreen';
import CalendarScreen from './screens/CalendarScreen';
import DashBoardSceen from './screens/DashBoardScreen';
import SettingsScreen from './screens/SettingsScreen';

import DropdownCategory from './components/DropdownCategory'

// const Stack = createNativeStackNavigator()
const Tab = createBottomTabNavigator()

export default function App() {
  const [caretType, setCaretType] = useState(false)
  // console.log("캐럿[app]: ", caretType)

  return (
    <NavigationContainer>
      <Tab.Navigator initialRouteName = "Home" screenOptions={{
        tabBarActiveTintColor: '#a8c8ffff',
        // tabBarStyle: {
        //   backgroundColor: '#333'
        // }
      }}>
        <Tab.Screen name="Home" children={(props) => <HomeScreen {...props} caretType={caretType} setCaretType={setCaretType}/>} options={{
          title: '홈',
          tabBarIcon: ({ color, size }) => <Icon name="home" color={color} size={size}/>,
          headerTitle: (props) => <DropdownCategory {...props} caretType={caretType} setCaretType={setCaretType}/>,
          headerStyle: {
            backgroundColor: '#a8c8ffff',
          },
          headerTitleStyle: {
            fontWeight: 'bold',
            color: '#fff'
          },
        }}/>
        <Tab.Screen name="Calendar" component={CalendarScreen} options={{
          title: '달력',
          tabBarIcon: ({ color, size }) => <Icon name="calendar-month" color={color} size={size}/>
        }}/>
        <Tab.Screen name="DashBoard" component={DashBoardSceen} options={{
          title: '통계',
          tabBarIcon: ({ color, size }) => <Icon name="dashboard" color={color} size={size}/>
        }}/>
        <Tab.Screen name="Settings" component={SettingsScreen} options={{
          title: '설정',
          tabBarIcon: ({ color, size }) => <Icon name="settings" color={color} size={size}/>
        }}/>
      </Tab.Navigator>
    </NavigationContainer>
  );
}

App.js 파일을 위와 같이 수정한다. 

import React from 'react'

드롭다운 메뉴를 열고 닫기 위한 state 를 정의하기 위하여 아래와 같이 useState 를 추가적으로 임포트한다.

import React, { useState } from 'react'

드롭다운을 열고 닫기 위한 상태(caretType)와 해당 상태를 변경하기 위한 setCaretType 함수를 정의한다. 

const [caretType, setCaretType] = useState(false)
// console.log("캐럿[app]: ", caretType)

components 폴더의 DropdownCategory 컴포넌트를 임포트한다.

import DropdownCategory from './components/DropdownCategory'

해당 컴포넌트는 홈화면의 헤더에 위치하는 드롭다운 메뉴를 열고 닫는 버튼이다. 

<Tab.Screen name="Home" children={(props) => <HomeScreen {...props} caretType={caretType} setCaretType={setCaretType}/>} options={{
          title: '홈',
          tabBarIcon: ({ color, size }) => <Icon name="home" color={color} size={size}/>,
          headerTitle: (props) => <DropdownCategory {...props} caretType={caretType} setCaretType={setCaretType}/>,
          headerStyle: {
            backgroundColor: '#a8c8ffff',
          },
          headerTitleStyle: {
            fontWeight: 'bold',
            color: '#fff'
          },
        }}/>

해당 부분이 대대적으로 수정되었다. 

component={HomeScreen}

탭 네비게이션의 홈화면에는 아래와 같은 추가적인 속성들을 사용할 예정이므로 위와 같이 component 속성에 단순히 HomeScreen 이라는 컴포넌트 이름만 설정해주는 것이 아니라 아래와 같이 children 속성에 함수 형태로 컴포넌트를 설정해준다. HomeScreen 컴포넌트에는 Tab.Screen 컴포넌트가 제공하는 기본적인 탭 네비게이션을 위한 속성들(navigation 등)이 props로 전달된다. 이때 스프레드 연산자를 사용하여 한꺼번에 전달한다. 

children={(props) => <HomeScreen {...props} caretType={caretType} setCaretType={setCaretType}/>}

HomeScreen 컴포넌트 내부에서는 드롭다운 메뉴의 상태(caretType)에 따라 드롭다운 메뉴를 열고 닫는다. 또한, 홈화면의 공백을 터치하는 경우 드롭다운 메뉴를 닫기 위하여 setCaretType 함수도 함께 props 로 전달한다. 

headerTitle: (props) => <DropdownCategory {...props} caretType={caretType} setCaretType={setCaretType}/>,

홈화면의 헤더 위치에 드롭다운 메뉴를 열고 닫기 위한 버튼(DropdownCategory) 컴포넌트를 보여주기 위하여 Tab.Screen 컴포넌트의 options 속성에 headerTitle 에 해당 컴포넌트를 설정한다. DropdownCategory 컴포넌트로 전달되는 props 는 Tab.Screen 컴포넌트의 headerTitle 에서 기본적으로 전달되는 속성이다. 드롭다운 메뉴 버튼을 클릭할때마다 caretType 값은 토글된다. caretType 값을 변경하기 위하여 setCaretType 함수도 함께 전달한다. 

headerStyle: {
    backgroundColor: '#a8c8ffff',
},
headerTitleStyle: {
    fontWeight: 'bold',
    color: '#fff'
},

headerStyle 에는 홈화면의 헤더 배경색을 설정하고, headerTitleStyle 은 홈화면 헤더의 텍스트를 디자인한다.  

 

* 드롭다운 버튼 구현하기

https://oblador.github.io/react-native-vector-icons/

 

react-native-vector-icons directory

 

oblador.github.io

해당 사이트에서 아이콘을 검색할 수 있다.

 

import React from 'react'
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import AntIcon from 'react-native-vector-icons/AntDesign'

const caretdownComponent = (props) => <AntIcon name="caretdown" {...props} size={15}/>
const caretupComponent = (props) => <AntIcon name="caretup" {...props} size={15}/>

function DropdownCategory({ caretType, setCaretType }) {
    // console.log("캐럿다운 컴포넌트: ", caretdownComponent())
    // console.log("캐럿업 컴포넌트: ", caretupComponent())
    const onPress = () => {
        setCaretType(!caretType)
    }
    return (
        <TouchableOpacity onPress={onPress}>
            <View style={[styles.container, caretType && { alignItems: 'flex-end' }]}>
                <Text style={styles.categoryText}>카테고리</Text>
                {caretType ? 
                  caretupComponent()
                : caretdownComponent() 
                }
            </View>
        </TouchableOpacity>
    )
}

const styles = StyleSheet.create({
    container: {
        flexDirection: 'row',
        alignItems: 'center',
        // borderColor: 'red',
        // borderWidth: 1
    },
    categoryText: {
        paddingRight: 5,
        fontSize: 15
    }
})

export default DropdownCategory

components > DropdownCategory.js 파일을 생성하고 위와 같이 작성한다. 

import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'

드롭다운 버튼 구현에 필요한 컴포넌트를 임포트한다. TouchableOpacity 컴포넌트는 버튼을 커스터마이징할 수 있다.

import AntIcon from 'react-native-vector-icons/AntDesign'

드롭다운 버튼에 포함되는 화살표(up, down)를 보여주기 위하여 AntDesign 아이콘을 사용하도록 한다.

const caretdownComponent = (props) => <AntIcon name="caretdown" {...props} size={15}/>
const caretupComponent = (props) => <AntIcon name="caretup" {...props} size={15}/>

caretdownComponent 는 아래방향 화살표를 보여준다. 이때 AntIcon 컴포넌트를 이용한다. AntIcon 컴포넌트는 라이브러리에서 기본적으로 제공하는 props 를 전달받을 수 있다. 자세한 props 는 콘솔에 출력된 값을 확인하면 된다. AntIcon 이 기본적으로 제공하는 props 중 size 값은 위와 같이 값을 재정의할 수 있다. caretupComponent 는 위쪽방향 화살표를 화면에 보여준다. 

function DropdownCategory({ caretType, setCaretType }) {
}

DropdownCategory 컴포넌트는 부모 컴포넌트로부터 화살표종류를 의미하는 caretType 상태를 props 로 전달받는다. 물론 caretType 상태를 변경하는 setCaretType 함수도 전달받는다.

// console.log("캐럿다운 컴포넌트: ", caretdownComponent())
// console.log("캐럿업 컴포넌트: ", caretupComponent())

화살표 아이콘 컴포넌트를 콘솔에 출력해서 확인한다.

const onPress = () => {
    setCaretType(!caretType)
}

드롭다운 버튼을 클릭할때마다 caretType 을 토글(toggle)해서 드롭다운 메뉴를 화면에 보여주거나 숨긴다. 

<TouchableOpacity onPress={onPress}>
    <View style={[styles.container, caretType && { alignItems: 'flex-end' }]}>
        <Text style={styles.categoryText}>카테고리</Text>
        {caretType ? 
          caretupComponent()
        : caretdownComponent() 
        }
    </View>
</TouchableOpacity>

TouchableOpacity 컴포넌트를 클릭할때 이벤트가 실행될 수 있도록 onPress 를 등록한다. 삼항연산자를 이용하여 caretType 상태에 따라 화살표 아이콘을 변경(위->아래, 아래->위)한다. caretType 이 true 인 경우에는 alignItems 값을 'flex-end' 로 설정한다. 이렇게 하는 이유는 위쪽 화살표가 화면에서 조금 위쪽에 있어서 화살표를 아래로 조금 내려주기 위함이다. 

const styles = StyleSheet.create({
    container: {
        flexDirection: 'row',
        alignItems: 'center',
        // borderColor: 'red',
        // borderWidth: 1
    },
    categoryText: {
        paddingRight: 5,
        fontSize: 15
    }
})

리액트 네이티브에서 flex 방향은 기본적으로 column 이다. 그래서 컨테이너 내부의 아이템들을 수평으로 나열하기 위해서는 flex 방향을 row 로 설정해야 한다. 

 

* 홈화면에서 드롭다운 메뉴 보여주거나 숨기기

import React, { useState, useEffect } from 'react'
import { 
  SafeAreaView, 
  View, Text, 
  StyleSheet, 
  StatusBar, 
  Keyboard, 
  FlatList, // 드롭다운 메뉴 컨테이너 (추가)
  TouchableHighlight // 추가 
} from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'
import DropdownItem from '../components/DropdownItem' // 드롭다운 아이템 (추가)

function HomeScreen({ navigation, caretType, setCaretType }){ // caretType, setCaretType props (추가)
  const date = new Date()
  const categories = ['자기계발', '업무', '오락', '여행', '연애', 'IT', '취미'] // 카테고리 배열 (추가)
  const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])
  const [todoText, setTodoText] = useState('')
  const [warning, setWarning] = useState(false)

  const onInsertTodo = (trimedText) => {
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: todoContents[1] || '자기계발', //
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        setTodos([newTodo, ...todos]) // 최신순 정렬하기 (수정)
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
      setWarning(true)
    }
  }

  const closeDropdown = () => { // 드롭다운 숨기기(추가)
    caretType && setCaretType(false)
  }
  const handleOutSideOfMenu = () => { // 드롭다운 메뉴 이외 영역 터치시 드롭다운 숨기기(추가)
    console.log('홈화면을 터치하셨습니다.')
    closeDropdown()
  }

  useEffect(() => navigation.addListener('focus', () => console.log('페이지 로딩')), []) // 추가

  useEffect(() => navigation.addListener('blur', () => console.log('페이지 벗어남')), []) // 추가

  return (
    <SafeAreaView 
        style={styles.block} 
        onTouchStart={handleOutSideOfMenu} // 홈화면 터치시(수정)
    >
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      
        {caretType // 드롭다운 보여주기 (추가)
        && (
            <View style={styles.dropdownShadow}>
              <FlatList
                data={categories} 
                keyExtractor={item => item}
                renderItem={({item}) => (
                  <DropdownItem category={item} closeDropdown={closeDropdown}/> // 아이템 각각의 뷰 화면
                )}
                style={styles.dropdownList}
              />
          </View>
        )}
        
        <DateHeader date={date}/>
        {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
        <TodoInsert 
          onInsertTodo={onInsertTodo} 
          todoText={todoText} 
          setTodoText={setTodoText} 
          warning={warning} 
          setWarning={setWarning}/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  },
  dropdownList: { // 드롭다운 컨테이너 스타일 (추가)
    padding: 5
  },
  dropdownShadow: { // 드롭다운 메뉴 그림자 스타일 (추가)
    shadowOffset: { width: 0, height: 20 },
    shadowColor: '#000',
    shadowOpacity: 0.25,
    backgroundColor : "#fff", // invisible color
    zIndex: 1,
    elevation: 1,
    position: 'absolute',
    top: -15,
    borderRadius: 5,
    margin: 15
  }
})
export default HomeScreen

screens > HomeScreen.js 파일을 위와 같이 수정한다. 

import { 
  SafeAreaView, 
  View, Text, 
  StyleSheet, 
  StatusBar, 
  Keyboard, 
  FlatList,
  TouchableHighlight 
} from 'react-native'

드롭다운 메뉴를 보여주기 위하여 리스트 뷰를 보여주는 FlatList 컴포넌트를 임포트한다. 

import DropdownItem from '../components/DropdownItem' // 드롭다운 아이템 (추가)

드롭다운 메뉴에 포함된 각각의 메뉴들을 보여주기 위하여 DropdownItem 컴포넌트를 임포트한다. 현재 해당 컴포넌트는 아직 만들지 않았기 때문에 오류가 발생할 것이다. 홈화면 코드를 수정한 후 생성하기로 하자!

function HomeScreen({ navigation, caretType, setCaretType }){ // caretType, setCaretType props (추가)
}

HomeScreen 컴포넌트에서 caretType 에 따라 드롭다운 메뉴를 보여주거나 숨기기 위하여 caretType 을 props 로 전달받는다. 또한, 특정 이벤트에서 드롭다운 메뉴를 숨기기 위하여 caretType 상태를 변경할 setCaretType 함수도 props 로 전달받는다. 

const categories = ['자기계발', '업무', '오락', '여행', '연애', 'IT', '취미'] // 카테고리 배열 (추가)

카테고리를 화면에 보여주기 위하여 카테고리 배열을 추가한다.

setTodos([newTodo, ...todos]) // 최신순 정렬하기 (수정)

할일을 추가할때 최근에 작성한 할일이 상위에 보여지도록 위와 같이 코드를 수정한다.

const closeDropdown = () => { // 드롭다운 숨기기(추가)
    caretType && setCaretType(false)
}
const handleOutSideOfMenu = () => { // 드롭다운 메뉴 이외 영역 터치시 드롭다운 숨기기(추가)
    console.log('홈화면을 터치하셨습니다.')
    closeDropdown()
}

closeDropdown 함수는 드롭다운 메뉴에서 특정 카테고리를 선택할때 실행된다. 사용자가 카테고리를 선택하면 드롭다운 메뉴를 화면에서 숨겨야 하므로 위와 같이 setCaretType 함수에서 caretType 을 false 로 변경한다. handleOutSideOfMenu 함수는 홈화면을 터치할때 실행된다. 즉, 드롭다운 메뉴 이외의 영역을 터치하는 경우 드롭다운 메뉴를 화면에서 숨긴다.

useEffect(() => navigation.addListener('focus', () => console.log('페이지 로딩')), []) // 추가

useEffect(() => navigation.addListener('blur', () => console.log('페이지 벗어남')), []) // 추가

추후 기능추가를 위하여 작성한 코드이다. 현재는 사용되지 않는다. 페이지가 처음 로딩되거나 해당 페이지를 벗어날때 실행된다. 

<SafeAreaView 
        style={styles.block} 
        onTouchStart={handleOutSideOfMenu} // 홈화면 터치시(수정)
    >

onTouchStart 이벤트는 홈화면을 터치할때 발생한다. 이때 handleOutSideOfMenu 함수를 실행하여 드롭다운 메뉴를 화면에서 숨긴다. 

{caretType // 드롭다운 보여주기 (추가)
        && (
            <View style={styles.dropdownShadow}>
              <FlatList
                data={categories} 
                keyExtractor={item => item}
                renderItem={({item}) => (
                  <DropdownItem category={item} closeDropdown={closeDropdown}/> // 아이템 각각의 뷰 화면
                )}
                style={styles.dropdownList}
              />
          </View>
        )}

단락평가를 이용하여 caretType 상태가 true 인 경우 드롭다운 메뉴를 화면에 보여준다. FlatList 컴포넌트를 이용하여 리스트 아이템들을 감싸준다. FlatList 컴포넌트를 다시 View 로 감싸준 이유는 드롭다운 메뉴에 그림자(shadow)를 적용하기 위함이다. FlatList 컴포넌트의 data 속성에는 카테고리 배열을 전달한다. keyExtractor 속성에는 카테고리 배열의 원소(element)를 전달한다. renderItem 속성은 카테고리 배열의 원소(문자열)를 DropdownItem 컴포넌트에 전달하여 드롭다운 메뉴를 화면에 보여준다. DropdownItem 컴포넌트의 closeDropdown 속성에 closeDropdown 함수를 props 로 전달하여 사용자가 특정 카테고리 메뉴를 선택할때 드롭다운 메뉴를 화면에서 숨길수 있도록 한다.

dropdownList: { // 드롭다운 컨테이너 스타일 (추가)
    padding: 5
  },
  dropdownShadow: { // 드롭다운 메뉴 그림자 스타일 (추가)
    shadowOffset: { width: 0, height: 20 },
    shadowColor: '#000',
    shadowOpacity: 0.25,
    backgroundColor : "#fff", // invisible color
    zIndex: 1,
    elevation: 1,
    position: 'absolute',
    top: -15,
    borderRadius: 5,
    margin: 15
  }

드롭다운 메뉴에 그림자(shadow)를 설정하는 코드이다. 드롭다운 메뉴에 position: 'absolute' 와 zIndex: 1 을 설정하여 화면에서 드롭다운 메뉴가 가리지 않고 맨 위에 독립적으로 보여지도록 한다. elevation 은 zIndex 와 동일한 개념이며 안드로이드에서는 elevation 이 적용된다. 

 

* TODO 에 드롭다운에서 선택한 카테고리 추가로 보여주기

import React, { useState, useEffect, useRef } from 'react' // 카테고리 저장을 위한 useRef 임포트(수정)
import { 
  SafeAreaView, 
  View, Text, 
  StyleSheet, 
  StatusBar, 
  Keyboard, 
  FlatList,
  TouchableHighlight 
} from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'
import DropdownItem from '../components/DropdownItem'

function HomeScreen({ navigation, caretType, setCaretType }){
  const date = new Date()
  const categories = ['자기계발', '업무', '오락', '여행', '연애', 'IT', '취미']
  const [todos, setTodos] = useState([
    {id: 1, title: '공원에 산책가기', category: '여행', createdAt: '2023-08-22', isDone: false},
    {id: 2, title: '보고서 작성하기', category: '업무', createdAt: '2023-08-22', isDone: true},
    {id: 3, title: '자기전에 책읽기', category: '자기계발', createdAt: '2023-08-22', isDone: false},
  ])
  const [todoText, setTodoText] = useState('')
  const [warning, setWarning] = useState(false)
  // const [category, setCategory] = useState('')
  const category = useRef('') // 카테고리 변수(추가)


  const onInsertTodo = (trimedText) => {
    if(!category.current){ // 카테고리를 선택하지 않은 경우(추가)
      setTodoText('카테고리를 먼저 선택해주세요!')
      setWarning(true)
      return 
    }
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      const nextId = todos.length + 1
      const todoContents = trimedText.split(',')
      const createdTime = new Date()

      const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: category.current || '자기계발', // 선택한 카테고리 설정 (수정)
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }
      if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        setTodos([newTodo, ...todos]) // 최신순 정렬하기
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
        category.current = '' // 카테고리 초기화 (추가)
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
      setWarning(true)
    }
  }

  const closeDropdown = () => {
    caretType && setCaretType(false)
  }
  const selectCategory = (item, e) => { // 카테고리 드롭다운 선택시 (추가)
    console.log("카테고리: ", item)
    closeDropdown()
    category.current = item 
  }
  const handleOutSideOfMenu = (e) => {
    console.log('홈화면을 터치하셨습니다.')
    closeDropdown()
  }

  useEffect(() => navigation.addListener('focus', () => console.log('페이지 로딩')), [])

  useEffect(() => navigation.addListener('blur', () => console.log('페이지 벗어남')), [])

  return (
    <SafeAreaView 
        style={styles.block} 
        onTouchStart={handleOutSideOfMenu}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      
        {caretType 
        && (
            <View 
              style={styles.dropdownShadow}
              onTouchStart={(e) => { // 터치 시작점 설정 : 캡쳐링 방지 (추가)
                console.log('여기를 지나침')
                e.stopPropagation() // 터치 버블링 방지
              }}
              >
              <FlatList
                data={categories} 
                keyExtractor={item => item}
                renderItem={({item}) => (
                  <DropdownItem category={item} selectCategory={(e) => selectCategory(item, e)}/> // 아이템 각각의 뷰 화면 : 카테고리 선택시 이벤트핸들러 함수 등록 (수정)
                )}
                style={styles.dropdownList}
              />
          </View>
        )}
        <DateHeader date={date}/>
        {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
        <TodoInsert 
          onInsertTodo={onInsertTodo} 
          todoText={todoText} 
          setTodoText={setTodoText} 
          warning={warning} 
          setWarning={setWarning}/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  },
  dropdownList: {
    padding: 5
  },
  dropdownShadow: {
    shadowOffset: { width: 0, height: 20 },
    shadowColor: '#000',
    shadowOpacity: 0.25,
    backgroundColor : "#fff", // invisible color
    zIndex: 1,
    elevation: 1,
    position: 'absolute',
    top: -15,
    borderRadius: 5,
    margin: 15
  }
})
export default HomeScreen

screens > HomeScreen.js 파일을 위와 같이 수정한다. 

 

import React, { useState, useEffect, useRef } from 'react' // 카테고리 저장을 위한 useRef 임포트(수정)

카테고리 데이터는 화면에 보여주기는 하지만 직접적으로 return 구문에서 렌더링하지 않는 값이고, 변경되는 값이기 때문에 useRef 훅으로 정의하는 것이 좋다. 

// const [category, setCategory] = useState('')
  const category = useRef('') // 카테고리 변수(추가)

사용자가 선택한 카테고리를 저장하기 위한 변수를 선언한다.

const onInsertTodo = (trimedText) => {
    if(!category.current){ // 카테고리를 선택하지 않은 경우(추가)
      setTodoText('카테고리를 먼저 선택해주세요!')
      setWarning(true)
      return 
    }
    // 중략
 }

사용자가 카테고리를 선택하지 않고 TODO 추가버튼을 클릭하면 입력창에 "카테고리를 먼저 선택해주세요!" 라는 안내문구를 보여준다. 그리고 TODO 를 추가하는 나머지 과정은 실행되지 않고 함수는 종료된다.

const newTodo = {
        id: todos.length + 1,
        title: todoContents[0],
        category: category.current || '자기계발', // 선택한 카테고리 설정 (수정)
        createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
      }

새로운 할일을 생성할때 사용자가 선택한 카테고리(useRef 로 정의한 변수)를 이용한다. 

if(todos.filter(todo => todo.title === newTodo.title).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        setTodos([newTodo, ...todos]) // 최신순 정렬하기
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
        category.current = '' // 카테고리 초기화 (추가)
      }

사용자가 에러없이 TODO 를 추가하는 경우 category 변수를 초기화한다. 

const selectCategory = (item, e) => { // 카테고리 드롭다운 선택시 (추가)
    console.log("카테고리: ", item)
    closeDropdown()
    category.current = item 
  }

카테고리 드롭다운 메뉴에서 특정 카테고리를 선택할때 실행되는 이벤트핸들러 함수이다. 해당 함수가 실행되면 드롭다운이 닫히고, category 변수에 사용자가 선택한 카테고리가 저장된다. 

{caretType 
        && (
            <View 
              style={styles.dropdownShadow}
              onTouchStart={(e) => { // 터치 시작점 설정 : 캡쳐링 방지 (추가)
                console.log('여기를 지나침')
                e.stopPropagation() // 터치 버블링 방지
              }}
              >
              // 중략

홈화면 전체에 설정된 터치 이벤트와 TODO 추가버튼을 터치했을때의 이벤트가 충돌한다. 해당 코드는 이벤트가 캡쳐링되는 것을 방지하고, 터치 이벤트의 시작점을 View 위치로 설정한다. 이렇게 하면 홈화면의 터치 이벤트는 실행되지 않는다. 또한, TODO 추가버튼 터치후 이벤트가 홈화면의 터치 이벤트로 전달되는 버블링을 e.stopPropagation 으로 방지한다.

// 아이템 각각의 뷰 화면 : 카테고리 선택시 이벤트핸들러 함수 등록 (수정)
<DropdownItem category={item} selectCategory={(e) => selectCategory(item, e)}/>

카테고리 드롭다운 메뉴에서 특정 카테고리를 선택할때 실행할 이벤트핸들러 함수를 등록한다.

 

import React from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'

function DropdownItem({ category, selectCategory }){ // selectCategory 함수 전달 (수정)
    return (
        <TouchableOpacity onPress={selectCategory}>
            <View style={styles.dropdownItemContainer}>
                <Text>{category}</Text>
            </View>
        </TouchableOpacity>
    )
}

const styles = StyleSheet.create({
    dropdownItemContainer: {
        flex: 1,
        backgroundColor: '#fff',
        padding: 10,
    }
})

export default DropdownItem

components > DropdownItem.js 파일을 위와 같이 수정한다. 이제 드롭다운 메뉴에서 특정 메뉴를 선택할때 단순히 드롭다운만 화면에서 숨기는 것이 아니라 사용자가 선택한 카테고리를 저장하는 기능도 추가되었으므로 이벤트핸들러 함수를 교체한다. 

카테고리를 선택하지 않고, TODO 를 추가하려고 하면 사용자에게 아래와 같이 에러 메세지를 안내한다.

카테고리를 선택하지 않고 TODO 를 추가하려는 경우
카테고리 드롭다운에서 특정 카테고리를 선택하는 화면
카테고리 선택후 TODO 를 추가한 경우

카테고리를 선택하고 TODO 를 추가하면 위와 같이 새로운 TODO 가 할일목록에 정상적으로 추가된다.

728x90