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

6. 할일목록 수정 및 삭제 기능 만들기

syleemomo 2023. 9. 27. 11:53
728x90

https://velog.io/@minkyeong-ko/React-Native-%EC%9D%B8%EC%8A%A4%ED%83%80%EA%B7%B8%EB%9E%A8-%EA%B0%99%EC%9D%80-Like-%ED%9A%A8%EA%B3%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Double-tap-Animated

 

[React Native] 인스타그램 같은 좋아요 효과 구현하기 (Double tap, Animated)

최근 개인 프로젝트에서 좋아요 기능이 필요했다. 그래서 인스타그램처럼 사진을 두번 클릭하거나 하트 아이콘을 누르면 하트가 떴다가 사라지는 효과를 구현했고, 이를 정리해보려 한다. 간단

velog.io

 

* 할일 수정하기

할일목록은 탭을 한번만 하면 할일의 상태가 토글되도록 구현한다. 또한, 탭을 연속으로 두번하면 할일제목을 변경할 수 있도록 한다. 할일제목은 수정할때는 탭을 두번하고, 수정이 끝나고 입력창 이외에 포커싱되면 저절로 할일제목이 변경되도록 한다. 

import firestore from '@react-native-firebase/firestore';

const getRef = (collections) => {
    return firestore().collection(collections);
}
export const addData = async (collections, data) => {
    await getRef(collections).add(data)
    console.log(`${collections} : ${JSON.stringify(data)} added in firestore!`)
}
export const updateDate = async (collections, id, data) => {
    await getRef(collections).doc(id).update(data)
}
export const getCollection = (collections, onResult, onError, query, order, limit) => {
    let ref = getRef(collections)
    
    // 조건쿼리
    if(query && query.exists && query.condition && query.condition.length !== 0){
        for(let cond of query.condition){ // Multiple Query
            ref = ref.where(...cond)
        }
    }
    if(order && order.exists && order.condition && order.condition.length !== 0){
        ref = ref.orderBy(...order.condition)
    }
    if(limit && limit.exists && limit.condition){
        ref = ref.limit(limit.condition)
    }
    return ref.onSnapshot(onResult, onError)
}
export const getCurrentTime = () => {
    return firestore.FieldValue.serverTimestamp() // 파이어베이스 해당서버의 로컬시각
}
export const changeTimeFormat = (date) => { // 시간포멧변경 (Date -> Timestamp)
    return firestore.Timestamp.fromDate(date)
}

apis > firebase.js 파일에 특정 할일을 수정할 함수를 추가한다. 

export const updateDate = async (collections, id, data) => {
    await getRef(collections).doc(id).update(data)
}

updateDate 함수는 특정 컬렉션에서 id 값과 일치하는 하나의 도큐먼트를 파라미터로 주어진 data 객체로 업데이트한다. 

 

import React, { useState, useRef, useEffect } from 'react'
import { View, Text, StyleSheet, TouchableWithoutFeedback, TextInput, TouchableOpacity, Keyboard } from 'react-native'
import moment from 'moment'

import { 
    updateDate
  } from '../apis/firebase'

let lastTap = null 

function TodoItem({ id, title, category, isDone, createdAt }){
    console.log("할일 생성시각: ", title, createdAt)
    const [doubleTabbed, setDoubleTabbed] = useState(false)
    const [text, setText] = useState("")
    const inputRef = useRef(null)

    const handleDoubleTab = (e) => {
        console.log(inputRef.current)
        setDoubleTabbed(!doubleTabbed)
        setText(title)
    }
    const ishandleDoubleTap = () => {
        const now = Date.now() // 밀리세컨드초
        const delay = 300
        if(lastTap && (now - lastTap) < delay){
            return true 
        }else{
            lastTap = now
            return false  
        }
    }
    const handleTap = () => {
        updateDate('todos', id, {
            isDone: !isDone
        })
    }
    const handlePress = (e) => {
        if(ishandleDoubleTap()){
            handleDoubleTab()
            console.log("더블탭")
            handleTap()
        }else{
            handleTap()   
            console.log("------ 탭 ----------")
        }
    }
    const handleBlur = (e) => {
        e.stopPropagation()
        console.log("블러")
        setDoubleTabbed(!doubleTabbed)
        Keyboard.dismiss()
        updateDate('todos', id, {
            title: text.trim()
        })
    }
    const handleChange = (text) => {
        // if (/\n/.test(text)) { // 엔터키 입력시 
        //     Keyboard.dismiss()
        //     // inputRef.current.blur()

        // }else{
        //     setText(text)
        // }
        setText(text)
    }
    const hideKeyboard = (e) => {
        Keyboard.dismiss()
        // inputRef.current.blur()
      }
    useEffect(() => {
        if(inputRef.current){
            inputRef.current.focus()
        }
    })
    return (
        <TouchableWithoutFeedback onPress={handlePress} >
            <View style={styles.item}>
                <View style={styles.titleMargin} onTouchStart={(e) => {e.stopPropagation()}}>
                    {doubleTabbed ? 
                        (
                            <TouchableWithoutFeedback>
                                <TextInput 
                                    value={text} 
                                    onBlur={handleBlur} 
                                    ref={inputRef}
                                    onChangeText={handleChange} // 입력창에 글자를 입력할때
                                    // onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
                            />
                            </TouchableWithoutFeedback>
                        ) : 
                        <Text style={[styles.title, {textDecorationLine: (isDone && !doubleTabbed ) ? 'line-through': 'none'}]}>{title}</Text>}
                </View>
                <View>
                    <Text>{category} ({isDone ? "종료": "진행중"})</Text>
                    <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('YY-MM-DD hh:mm:ss')}</Text>
                </View>
            </View>
        </TouchableWithoutFeedback>
    )
}

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 React.memo(TodoItem)

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

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

기존 코드를 아래와 같이 변경한다. 리액트 훅과 요소참조를 사용하기 위하여 관련함수를 임포트한다. 탭을 할때 할일을 수정할 수 있도록 탭 이벤트를 적용할 수 있는 TouchableWithoutFeedback, TouchableOpacity 컴포넌트를 임포트한다. 또한, 할일을 수정하기 위한 TextInput 컴포넌트도 함께 임포트한다.

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

할일정보를 업데이트하기 위하여 기존에 만들어둔 updateDate 함수를 임포트한다.

import { 
    updateDate
  } from '../apis/firebase'

사용자가 마지막으로 탭한 시각을 저장하기 위하여 lastTap 이라는 변수를 사용한다.

let lastTap = null

사용자가 탭을 연속으로 두번했는지 판단하기 위한 상태변수와 해당 상태를 변경하는 함수를 정의한다.

const [doubleTabbed, setDoubleTabbed] = useState(false)

입력창의 텍스트와 해당 텍스트를 변경하기 위한 함수를 정의한다.

const [text, setText] = useState("")

동적으로 입력창에 포커스를 주기 위하여 요소 참조 변수를 정의한다.

const inputRef = useRef(null)

ishandleDoubleTap 함수는 사용자가 탭을 할때마다 실행되는 함수이다. 사용자가 탭을 할때마다 탭한 시각을 lastTap 변수에 저장해둔다. 그러다가 방금 탭한 시각이 직전에 탭한 시각에서 300ms 를 넘어가지 않으면 연속으로 탭하였다고 판단하여 더블탭으로 인식하고 true 값을 반환한다. 그렇지 않으면 false 값을 반환한다. 

const ishandleDoubleTap = () => {
        const now = Date.now() // 밀리세컨드초
        const delay = 300
        if(lastTap && (now - lastTap) < delay){
            return true 
        }else{
            lastTap = now
            return false  
        }
    }

사용자가 더블탭을 한 경우 실행되는 함수이다. 해당 경우 doubleTabbed 상태를 토글하여 true 로 변경한다. 또한, 입력창의 텍스트는 기존에 서버에 저장해둔 할일제목(title) 을 불러와서 보여준다. 

const handleDoubleTab = (e) => {
        console.log(inputRef.current)
        setDoubleTabbed(!doubleTabbed)
        setText(title)
    }

사용자가 탭을 한번만 한 경우 실행되는 함수이다. todos 컬렉션에서 id 값에 해당하는 도큐먼트를 찾아서 할일완료 여부를 담고 있는 isDone 값을 토글한다. 

const handleTap = () => {
        updateDate('todos', id, {
            isDone: !isDone
        })
    }

이때 Cloud Firestore 데이터베이스에 접근하여 데이터를 변경한다. 할일목록 중 특정 데이터가 변경되면 App 컴포넌트의 useEffect 안에 정의된 onResult 함수가 재실행되면서 업데이트가 완료된 최신 할일목록을 홈 화면에서 조회할 수 있다.  왜냐하면 getCollection 함수 내부에서 onSnapshot 메서드를 사용하는데 해당 메서드는 컬렉션 중 일부가 추가/변경/삭제 될때마다 데이터베이스에 접속하여 최신 컬렉션을 다시 조회하기 때문이다. 

useEffect(() => { // 할일목록 조회 (HomeScreen -> App 이동)
    function onResult(querySnapshot){
      const list = []
      querySnapshot.forEach(doc => {
        console.log(doc.data())
        list.push({
          ...doc.data(),
          id: doc.id,
        })
      })
      setTodos(list)
      setLoading(false)
    }
    function onError(error){
      console.error(`${error} occured when reading todos`)
    }
    return getCollection('todos', 
                          onResult, onError,
                          null, null, null)
  }, [])

사용자가 할일목록 중 하나의 할일을 탭한 경우 실행되는 함수이다. 조건문을 이용하여 더블탭인지 싱글탭인지 판단하고 각 경우에 따라 적절한 함수를 실행한다. 더블탭인 경우에도 handleTap 이라는 함수를 실행하는 이유는 더블탭이 싱글탭을 포함하고 있기 때문이다. 더블탭이 되려면 일단 싱글탭이 먼저 실행될 수 밖에 없다. 이렇게 되면 할일종료 여부에 대한 상태가 변경되어 버린다. 하지만 더블탭인 경우에는 할일종료 여부를 변경하면 안되기 때문에 싱글탭 실행을 무마시키기 위하여 handleTap 함수를 더블탭에서도 한번더 실행시켜 준 것이다. 이렇게 하지 않으면 더블탭인 경우에 할일제목이 바뀌면서 할일종료 여부에 대한 상태도 바뀌어버린다. 

const handlePress = (e) => {
        if(ishandleDoubleTap()){
            handleDoubleTab()
            console.log("더블탭")
            handleTap()
        }else{
            handleTap()   
            console.log("------ 탭 ----------")
        }
    }

사용자가 입력창에 글자를 입력할때마다 텍스트를 변경해주는 함수이다. 

const handleChange = (text) => {
        // if (/\n/.test(text)) { // 엔터키 입력시 
        //     Keyboard.dismiss()
        //     // inputRef.current.blur()

        // }else{
        //     setText(text)
        // }
        setText(text)
    }

handleBlure 함수는 사용자가 입력창에서 할일제목을 변경한 다음에 입력창에서 포커스를 제거하면 실행되는 함수이다. 입력창에서 포커스가 제거될때 doubleTabbed 상태를 false 로 변경하여 입력창 대신에 단순한 텍스트가 보여지도록 한다. 사용자가 수정을 완료하였으므로 키보드는 보이지 않도록 한다. 또한, 입력창에서 포커스가 사라질때 입력창에 입력한 텍스트로 데이터베이스에서 특정 할일제목(title)을 변경한다. 이때도 미리 정의해둔 updateDate 함수를 이용한다. 마지막으로 e.stopPropagation 을 실행하여 blur 이벤트가 상위로 버블링되지 않도록 한다. 왜냐하면 상위에 TodoInsert 컴포넌트에  blur 이벤트가 영향을 미칠수 있기 때문이다. 

const handleBlur = (e) => {
        e.stopPropagation()
        console.log("블러")
        setDoubleTabbed(!doubleTabbed)
        Keyboard.dismiss()
        updateDate('todos', id, {
            title: text.trim()
        })
    }

해당 함수는 사용자 입력이 완료되면 실행되는 함수이다. 하지만 현재는 사용하고 있지 않다.

const hideKeyboard = (e) => {
        Keyboard.dismiss()
        // inputRef.current.blur()
      }

사용자가 더블탭을 한 경우 입력창이 보이고, 입력창이 있으면 해당 입력창에 포커스를 적용한다. 이렇게 하지 않으면 blur 이벤트가 발생되지 않고, handleBlur 함수가 실행되지 않아서 할일제목이 업데이트되지 않는다. 

useEffect(() => {
        if(inputRef.current){
            inputRef.current.focus()
        }
    })

 

<View style={styles.item}>
            <View style={styles.titleMargin}>
                <Text style={styles.title}>{title}</Text>
            </View>
            <View>
                <Text>{category} ({isDone ? "종료": "진행중"})</Text>
                <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('YY-MM-DD hh:mm:ss')}</Text>
</View>

기존에 TodoItem 컴포넌트가 보여주는 화면은 아래와 같다. 하지만 더블탭 여부에 따라 입력창을 보여주거나 평범한 텍스트를 보여주어야 하므로 해당 화면을 다음과 같이 수정하도록 한다. 

<TouchableWithoutFeedback onPress={handlePress} >
            <View style={styles.item}>
                <View style={styles.titleMargin} onTouchStart={(e) => {e.stopPropagation()}}>
                    {doubleTabbed ? 
                        (
                            <TouchableWithoutFeedback>
                                <TextInput 
                                    value={text} 
                                    onBlur={handleBlur} 
                                    ref={inputRef}
                                    onChangeText={handleChange} // 입력창에 글자를 입력할때
                                    // onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
                            />
                            </TouchableWithoutFeedback>
                        ) : 
                        <Text style={[styles.title, {textDecorationLine: (isDone && !doubleTabbed ) ? 'line-through': 'none'}]}>{title}</Text>}
                </View>
                <View>
                    <Text>{category} ({isDone ? "종료": "진행중"})</Text>
                    <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('YY-MM-DD hh:mm:ss')}</Text>
                </View>
 </TouchableWithoutFeedback>

우선 할일제목을 탭할때 handlePress 함수가 실행되도록 TouchableWithoutFeedback 컴포넌트로 한번 감싸주었다.

<TouchableWithoutFeedback onPress={handlePress} >
	// 중략
</TouchableWithoutFeedback>

onTouchStart 는 해당 View 컴포넌트 내부에서 발생한 이벤트가 상위로 버블링(전파)되지 않도록 방어막을 형성하여 이벤트 충돌을 막아준다. 

<View style={styles.titleMargin} onTouchStart={(e) => {e.stopPropagation()}}>
	// 중략
</View>

doubleTabbed 상태에 따라 입력창을 보여주거나 일반 텍스트를 보여준다. 즉, 사용자가 더블탭을 하면 할일제목을 수정할 수 있도록 입력창을 보여주고, 그렇지 않으면 평범한 할일제목을 보여준다. 

{doubleTabbed ? 
                        (
                            <TouchableWithoutFeedback>
                                <TextInput 
                                    value={text} 
                                    onBlur={handleBlur} 
                                    ref={inputRef}
                                    onChangeText={handleChange} // 입력창에 글자를 입력할때
                                    // onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
                            />
                            </TouchableWithoutFeedback>
                        ) : 
                        <Text style={[styles.title, {textDecorationLine: (isDone && !doubleTabbed ) ? 'line-through': 'none'}]}>{title}</Text>}

TextInput 에 포커스를 주기 위하여 ref 를 설정한다. 입력창에서 blur 이벤트가 발생할때 실행할 handleBlur 함수를 등록한다. 입력창에 글자를 입력할때 실행할 handleChange 함수도 등록한다. onSubmitEditing 이벤트는 엔터키를 두번 눌러야 동작하므로 사용하지 않는다. Text 컴포넌트는 더블탭이 아니고, isDone 이 true 이면 line-through 스타일을 적용한다. 

 

* 할일 수정하기 테스트 

할일완료 여부 변경하기
할일제목 변경하기 1
할일제목 변경하기 2

 

* 할일 삭제하기

https://reactnative.dev/docs/modal

 

Modal · React Native

The Modal component is a basic way to present content above an enclosing view.

reactnative.dev

 

import firestore from '@react-native-firebase/firestore';

const getRef = (collections) => {
    return firestore().collection(collections);
}
export const addData = async (collections, data) => {
    await getRef(collections).add(data)
    console.log(`${collections} : ${JSON.stringify(data)} added in firestore!`)
}
export const updateDate = async (collections, id, data) => {
    await getRef(collections).doc(id).update(data)
}
export const removeData = async (collections, id) => {
    await getRef(collections).doc(id).delete()
}
export const getCollection = (collections, onResult, onError, query, order, limit) => {
    let ref = getRef(collections)
    
    // 조건쿼리
    if(query && query.exists && query.condition && query.condition.length !== 0){
        for(let cond of query.condition){ // Multiple Query
            ref = ref.where(...cond)
        }
    }
    if(order && order.exists && order.condition && order.condition.length !== 0){
        ref = ref.orderBy(...order.condition)
    }
    if(limit && limit.exists && limit.condition){
        ref = ref.limit(limit.condition)
    }
    return ref.onSnapshot(onResult, onError)
}
export const getCurrentTime = () => {
    return firestore.FieldValue.serverTimestamp() // 파이어베이스 해당서버의 로컬시각
}
export const changeTimeFormat = (date) => { // 시간포멧변경 (Date -> Timestamp)
    return firestore.Timestamp.fromDate(date)
}

apis > firebase.js 파일에 아래 코드를 추가한다. removeData 함수는 특정 컬렉션에서 특정 id 에 해당하는 도큐먼트를 데이터베이스에서 삭제한다. 

export const removeData = async (collections, id) => {
    await getRef(collections).doc(id).delete()
}

components > TodoItem.js 파일을 아래와 같이 수정한다. 

import React, { useState, useRef, useEffect } from 'react'
import { View, Text, StyleSheet, TouchableWithoutFeedback, TextInput, TouchableOpacity, Keyboard } from 'react-native'
import moment from 'moment'

import { 
    updateDate
  } from '../apis/firebase'

let lastTap = null 

function TodoItem({ id, title, category, isDone, createdAt, removeTodo }){
    console.log("할일 생성시각: ", title, createdAt)
    const [doubleTabbed, setDoubleTabbed] = useState(false)
    const [text, setText] = useState("")
    const inputRef = useRef(null)

    const handleDoubleTab = (e) => {
        console.log(inputRef.current)
        setDoubleTabbed(!doubleTabbed)
        setText(title)
    }
    const ishandleDoubleTap = () => {
        const now = Date.now() // 밀리세컨드초
        const delay = 300
        if(lastTap && (now - lastTap) < delay){
            return true 
        }else{
            lastTap = now
            return false  
        }
    }
    const handleTap = () => {
        updateDate('todos', id, {
            isDone: !isDone
        })
    }
    const handlePress = (e) => {
        if(ishandleDoubleTap()){
            handleDoubleTab()
            console.log("더블탭")
            handleTap()
        }else{
            handleTap()   
            console.log("------ 탭 ----------")
        }
    }
    const handleBlur = (e) => {
        e.stopPropagation()
        console.log("블러")
        setDoubleTabbed(!doubleTabbed)
        Keyboard.dismiss()
        updateDate('todos', id, {
            title: text.trim()
        })
    }
    const handleChange = (text) => {
        // if (/\n/.test(text)) { // 엔터키 입력시 
        //     Keyboard.dismiss()
        //     // inputRef.current.blur()

        // }else{
        //     setText(text)
        // }
        setText(text)
    }
    const hideKeyboard = (e) => {
        Keyboard.dismiss()
        // inputRef.current.blur()
      }
    const handleRemove = (e) => {
        e.stopPropagation()
        removeTodo(id, title)
    }
    useEffect(() => {
        if(inputRef.current){
            inputRef.current.focus()
        }
    })
    return (
        <TouchableWithoutFeedback onPress={handlePress} onLongPress={handleRemove}>
            <View style={styles.item}>
                <View style={styles.titleMargin} onTouchStart={(e) => {e.stopPropagation()}}>
                    {doubleTabbed ? 
                        (
                            <TouchableWithoutFeedback>
                                <TextInput 
                                    value={text} 
                                    onBlur={handleBlur} 
                                    ref={inputRef}
                                    onChangeText={handleChange} // 입력창에 글자를 입력할때
                                    // onSubmitEditing={hideKeyboard} // 여기서 하면 엔터키 두번 눌러야 할일추가됨 (키보드만 닫는걸로 수정함)
                            />
                            </TouchableWithoutFeedback>
                        ) : 
                        <Text style={[styles.title, {textDecorationLine: (isDone && !doubleTabbed ) ? 'line-through': 'none'}]}>{title}</Text>}
                </View>
                <View>
                    <Text>{category} ({isDone ? "종료": "진행중"})</Text>
                    <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('YY-MM-DD hh:mm:ss')}</Text>
                </View>
            </View>
        </TouchableWithoutFeedback>
    )
}

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 React.memo(TodoItem)

removeTodo 함수는 홈화면(상위 컴포넌트)에서 전달받는 props 이다. 

function TodoItem({ id, title, category, isDone, createdAt, removeTodo }){
	// 중략
}

handleRemove 함수는 특정 할일을 길게 탭할때 실행되는 함수이다. 이때 removeTodo 함수를 실행하면서 삭제하려는 TODO의 id 값과 title 값을 넘겨준다. 

const handleRemove = (e) => {
        e.stopPropagation()
        removeTodo(id, title)
    }

특정 할일을 길게 탭할때 handleRemove 가 실행될 수 있도록 onLongPress 이벤트에 해당 핸들러 함수를 등록한다. 

<TouchableWithoutFeedback onPress={handlePress} onLongPress={handleRemove}>
	// 중략
</TouchableWithoutFeedback>

components > TodoList.js 파일을 아래와 같이 수정한다. 

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

function TodoList({ todos, removeTodo }){
    return (
        <FlatList
            data={todos}
            style={styles.container}
            keyExtractor={item => item.id}
            ItemSeparatorComponent={() => <View style={styles.line}/>}
            renderItem={({item}) => (
                <TodoItem {...item} removeTodo={removeTodo}/> // 아이템 각각의 뷰 화면
            )}
        />
    )
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: '#fff',
    },
    line: {
        backgroundColor: '#ddd',
        height: 1,
    }
})

export default TodoList

TodoItem 컴포넌트에서 사용할 removeTodo 를 홈화면으로부터 전달받는다.

function TodoList({ todos, removeTodo }){
	// 중략
}

 TodoItem 컴포넌트로 removeTodo 함수를 내려준다. 

<TodoItem {...item} removeTodo={removeTodo}/> // 아이템 각각의 뷰 화면

 

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

import React, { useState, useEffect, useRef } from 'react' // 카테고리 저장을 위한 useRef 임포트(수정)
import { 
  addData,
  removeData,
  getCurrentTime,
  // getCollection // 주석처리
} from '../apis/firebase'

import { // 오늘과 내일 날짜기준을 계산하는 유틸리티 함수
  getToday,
  getTomorrow
} from '../utils/time' 

import { 
  SafeAreaView, 
  View, Text, 
  StyleSheet, 
  StatusBar, 
  Keyboard, 
  FlatList,
  TouchableHighlight,
    Modal, Pressable
} 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, todos, loading, route }){ // 필요한 데이터 추가 (todos, loading, route)
  const categories = ['자기계발', '업무', '오락', '여행', '연애', 'IT', '취미']
  const [todoText, setTodoText] = useState('')
  const [warning, setWarning] = useState(false)
  const [modalOpen, setModalOpen] = useState(false)
  const [todoToRemove, setTodoToRemove] = useState({id: null, title: ''})

  // 오늘/내일의 날짜를 기준으로 할일목록을 필터링하고 정렬함
  const category = useRef('') // 카테고리 변수
  const date = (route.params && route.params.date) ? new Date(route.params.date) : new Date()
  const today = getToday(date) // 시간제외
  const tomorrow = getTomorrow(getToday(date))
  const todosToday = todos.filter(todo => todo.createdAt?.toDate() >= today && todo.createdAt?.toDate() < tomorrow)
  const todosTodayLatest = [...todosToday] // 원본복사
  todosTodayLatest.sort((a, b) => b.createdAt.seconds - a.createdAt.seconds) // 최신순 정렬

  console.log("현재 선택날짜: ", date)
  console.log("날짜비교: ", date.getTime(), today.getTime() != getToday(new Date()).getTime())

  const onInsertTodo = async (trimedText) => {
    if(!category.current){ // 카테고리를 선택하지 않은 경우
      setTodoText('카테고리를 먼저 선택해주세요!')
      setWarning(true)
      return 
    }
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      if(todos.filter(todo => todo.title === trimedText).length > 0){
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        const newTodo = {
          title: trimedText,
          category: category.current || '자기계발', // 선택한 카테고리 설정 (수정)
          isDone: false,
          createdAt: getCurrentTime(), // 클라이언트 기준이 아니라 서버기준 저장시각
        }
        await addData('todos', newTodo)
        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()
  }
  const removeTodo = (id, title) => {
    setModalOpen(true)
    setTodoToRemove({id, title})
    console.log(`할일 [${title}] 제거`)
  }
  const handleRemove = () => {
    setModalOpen(false)
    removeData('todos', todoToRemove.id)
  }

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

  useEffect(() => navigation.addListener('blur', () => console.log('페이지 벗어남')), [])
  
  if (loading) {
    return (
      <View>
        <Text>로딩중...</Text>
      </View> 
    )
  }

  return (
    <SafeAreaView 
        style={styles.block} 
        onTouchStart={handleOutSideOfMenu}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      <Modal
        animationType="fade"
        transparent={true}
        visible={modalOpen}
        onRequestClose={() => {
          Alert.alert('Modal has been closed.');
          setModalOpen(!modalOpen);
        }}
      >
        <View style={styles.centeredView}>
          <View style={styles.modalView}>
            <Text style={styles.guideText}>할일 "{todoToRemove.title}" 을 제거하시겠습니까?</Text>
            <View style={styles.alignHorizontal}>
              <Pressable
                style={[styles.button, styles.buttonClose, styles.remove]}
                onPress={handleRemove}>
                <Text style={styles.textStyle}>삭제</Text>
              </Pressable>
              <Pressable
                style={[styles.button, styles.buttonClose]}
                onPress={() => setModalOpen(false)}>
                <Text style={styles.textStyle}>닫기</Text>
              </Pressable>
            </View>
          </View>
        </View>
      </Modal>
      
        {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}/>
        {/* 해당날짜 기준 최신순으로 정렬된 할일목록 */}
        {todosTodayLatest.length === 0 ? 
            <Default/> : 
            <TodoList todos={todosTodayLatest} removeTodo={removeTodo}
        />}
        {/* 필터링된 할일목록의 날짜와 현재 날짜가 동일하지 않은 경우 */}
        <TodoInsert 
          onInsertTodo={onInsertTodo} 
          todoText={todoText} 
          setTodoText={setTodoText} 
          warning={warning} 
          setWarning={setWarning}
          disabled={today.getTime()!==getToday(new Date()).getTime()}/> 
    </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
  },
  centeredView: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 22,
  },
  modalView: {
    margin: 50,
    backgroundColor: 'white',
    borderRadius: 20,
    padding: 20,
    alignItems: 'center',
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 4,
    elevation: 5,
  },
  alignHorizontal: {
    flexDirection: 'row',
    justifyContent: 'flex-end'
  },
  guideText: {
    fontWeight: 'bold',
    fontSize: 15
  },
  button: {
    width: 70,
    height: 40,
    borderRadius: 10,
    padding: 0,
    elevation: 2,
    marginTop: 30,
    marginRight: 5,
    justifyContent: 'center'
  },
  buttonOpen: {
    backgroundColor: '#F194FF',
  },
  buttonClose: {
    backgroundColor: '#a8c8ffff',
  },
  textStyle: {
    color: 'white',
    fontWeight: 'bold',
    textAlign: 'center',
  },
  remove: {
    backgroundColor: 'red'
  },
  modalText: {
    marginBottom: 15,
    textAlign: 'center',
  },
})
export default HomeScreen

할일을 삭제하기 위하여 기존에 만들어둔 removeData 함수를 임포트한다.

import { 
  addData,
  removeData,
  getCurrentTime,
  // getCollection // 주석처리
} from '../apis/firebase'

할일을 삭제하기 전에 사용자에게 주의사항을 안내하기 위하여 모달창을 보여줄 예정인데 그러기 위하여 Modal, Pressable 컴포넌트를 임포트한다.

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

모달창을 보여주고 숨기기 위하여 modalOpen 상태와 해당 상태를 변경하는 setModalOpen 함수를 정의한다.

const [modalOpen, setModalOpen] = useState(false)

사용자가 삭제할 할일을 길게 탭할때 해당 TODO의 id, title 을 임시로 저장하기 위한 todoToRemove 상태와 해당 상태를 변경하기 위한 setTodoToRremove 함수를 정의한다. 사용자가 삭제할 할일을 선택하는 시점과 모달창에서 실제 삭제 버튼을 누르는 시점이 다르기 때문에 삭제할 할일이 무엇인지 임시로 저장해두어야 한다. 

const [todoToRemove, setTodoToRemove] = useState({id: null, title: ''})

사용자가 삭제할 할일을 길게 탭할때 실행되는 함수이다. 이때 모달창을 화면에 보여주고, 삭제할 할일에 대한 id, title 값을 임시로 저장해둔다. 

const removeTodo = (id, title) => {
    setModalOpen(true)
    setTodoToRemove({id, title})
    console.log(`할일 [${title}] 제거`)
  }

모달창에서 삭제 버튼을 탭할때 실행되는 함수이다. 모달창을 닫고, 할일목록에서 특정 id 값에 해당하는 TODO 를 삭제한다. id 값은 todoToRemove 객체에 저장되어 있다. 또한, 할일을 삭제하고 나서 todoToRemove 상태를 초기화해준다. 

const handleRemove = () => {
    setModalOpen(false)
    setTodoToRemove({id: null, title: ''})
    removeData('todos', todoToRemove.id)
  }

모달창을 보여주기 위하여 Modal 컴포넌트를 추가한다.  

<Modal
        animationType="fade"
        transparent={true}
        visible={modalOpen}
        onRequestClose={() => {
          Alert.alert('Modal has been closed.');
          setModalOpen(!modalOpen);
        }}
      >
        <View style={styles.centeredView}>
          <View style={styles.modalView}>
            <Text style={styles.guideText}>할일 "{todoToRemove.title}" 을 제거하시겠습니까?</Text>
            <View style={styles.alignHorizontal}>
              <Pressable
                style={[styles.button, styles.buttonClose, styles.remove]}
                onPress={handleRemove}>
                <Text style={styles.textStyle}>삭제</Text>
              </Pressable>
              <Pressable
                style={[styles.button, styles.buttonClose]}
                onPress={() => setModalOpen(false)}>
                <Text style={styles.textStyle}>닫기</Text>
              </Pressable>
            </View>
          </View>
        </View>
      </Modal>

Modal 컴포넌트에는 애니메이션 종류를 선택할 수 있다. visible 은 불리언 값을 사용하여 모달창을 보여주거나 숨긴다. onRequestClose 이벤트는 모달창에서 X 버튼을 클릭할때 실행되는 함수이다. 현재는 사용하지 않는다. Alert 컴포넌트는 단순한 경고창을 보여준다. 

<Modal
        animationType="fade" // 페이딩 애니메이션
        transparent={true}
        visible={modalOpen}  // 모달창 보여주기/숨기기
        onRequestClose={() => {  // Close 버튼 클릭시 실행되는 함수 
          Alert.alert('Modal has been closed.');
          setModalOpen(!modalOpen);
        }}
      >
      // 중략
 </Modal>

아래 코드는 실제 모달창의 컨텐츠 내용을 보여준다. todoToRemove 상태에 저장된 삭제할 할일의 title 을 보여준다. 삭제버튼과 닫기 버튼을 안내문구 아래에 보여준다. 삭제버튼 클릭시 handleRemove 함수를 실행하여 실제로 선택한 할일을 삭제한다. 닫기 버튼을 클릭하면 모달창만 닫아준다. 

<View style={styles.centeredView}>
  <View style={styles.modalView}>
    <Text style={styles.guideText}>할일 "{todoToRemove.title}" 을 제거하시겠습니까?</Text>
    <View style={styles.alignHorizontal}>
      <Pressable
        style={[styles.button, styles.buttonClose, styles.remove]}
        onPress={handleRemove}>
        <Text style={styles.textStyle}>삭제</Text>
      </Pressable>
      <Pressable
        style={[styles.button, styles.buttonClose]}
        onPress={() => setModalOpen(false)}>
        <Text style={styles.textStyle}>닫기</Text>
      </Pressable>
    </View>
  </View>
</View>

TodoList 컴포넌트에 removeTodo 함수를 props 로 내려준다. 

<TodoList todos={todosTodayLatest} removeTodo={removeTodo}
        />

모달창에 대한 스타일을 정의한다. 

centeredView: {  // 모달창 중앙배치
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 22,
  },
  modalView: {   // 실제 모달창 디자인
    margin: 50,
    backgroundColor: 'white',
    borderRadius: 20,
    padding: 20,
    alignItems: 'center',
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 4,
    elevation: 5,
  },
  alignHorizontal: {   // 버튼 가로정렬
    flexDirection: 'row',
    justifyContent: 'flex-end'
  },
  guideText: {  // 모달창 안내문구 
    fontWeight: 'bold',
    fontSize: 15
  },
  button: {  // 버튼 디자인
    width: 70,
    height: 40,
    borderRadius: 10,
    padding: 0,
    elevation: 2,
    marginTop: 30,
    marginRight: 5,
    justifyContent: 'center'
  },
  buttonOpen: {
    backgroundColor: '#F194FF',
  },
  buttonClose: { // 닫기버튼 스타일
    backgroundColor: '#a8c8ffff',
  },
  textStyle: {   // 버튼 텍스트 스타일
    color: 'white',
    fontWeight: 'bold',
    textAlign: 'center',
  },
  remove: {  // 삭제버튼 스타일 
    backgroundColor: 'red'
  },
  modalText: {  // 
    marginBottom: 15,
    textAlign: 'center',
  },

 

* 할일 삭제기능 테스트

할일 삭제하기 1
할일 삭제하기 2

 

728x90