이제 무한 스크롤링 기능을 구현해 보겠습니다.

무한 스크롤링은 브라우저가 아래에 닿았을때, 저희가 마지막으로 보여주는 노트 아이템의 id를 기준으로 다음 10개의 노트 아이템들을 받아올 예정입니다.

NoteContainer에 다음을 추가해주겠습니다.

containers/NoteContainer.js

...

componentDidMount() {
  this.getNotes();
  // 스크롤링 이벤트 추가
  window.addEventListener("scroll", this.handleScroll);
}

componentWillUnmount() {
  // 언마운트 될때에, 스크롤링 이벤트 제거
  window.removeEventListener("scroll", this.handleScroll);
}

...

handleScroll = () => {
  const { innerHeight } = window;
  const { scrollHeight } = document.body;
  // IE에서는 document.documentElement 를 사용.
  const scrollTop =
    (document.documentElement && document.documentElement.scrollTop) ||
    document.body.scrollTop;
  // 스크롤링 했을때, 브라우저의 가장 밑에서 100정도 높이가 남았을때에 실행하기위함.
  if (scrollHeight - innerHeight - scrollTop < 100) {
    console.log("Almost Bottom Of This Browser");
  }
};

일단 스크롤링을 해야하므로 노트들을 20개정도 추가해주세요.

그리고 스크롤링해서 바닥에 닿으면 console에 로그가 찍힙니다.

그러면 이제 바닥에 거의 닿았을때, 더 많은 노트를 가져오는 API를 작성한후, redux처리를 해서 기능을 구현해야 합니다.

장고쪽으로 다시 돌아와주세요.

notes/views.py

class LoadMoreNotes(generics.ListAPIView):
    permission_classes = [permissions.IsAuthenticated, ]
    serializer_class = NoteSerializer

    def get(self, request, *args, **kwargs):
        flagId = kwargs['id']
        notes = Notes.objects.filter(owner=self.request.user).filter(id__lt=flagId).order_by('-created_at')[:10]
        isLast = False
        if len(notes) < 10:
            isLast = True
        serializer = self.get_serializer(notes, many=True, context={"request": request})
        return Response({
            "notes": serializer.data,
            "isLast": isLast
        })

notes/urls.py

from django.conf.urls import url
from .views import NoteViewSet, RegistrationAPI, LoginAPI, UserAPI, LoadMoreNotes
# LoadMoreNotes 가져오기


note_list = NoteViewSet.as_view({"get": "list", "post": "create"})

note_detail = NoteViewSet.as_view(
    {"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
)


urlpatterns = [
    url("^notes/$", note_list, name="note-list"),
    url("^notes/(?P<pk>[0-9]+)/$", note_detail, name="note-detail"),
    url("^notes/next/(?P<id>[0-9]+)/$", LoadMoreNotes.as_view()),
    # 상단을 추가해줍니다
    url("^auth/register/$", RegistrationAPI.as_view()),
    url("^auth/login/$", LoginAPI.as_view()),
    url("^auth/user/$", UserAPI.as_view()),
]

url: /api/notes/next/{id}/

이렇게 되면 url의 id를 바탕으로 작성자의 노트중 id 이후의 10개를 가져옵니다.

그럼 이제 API작업은 끝났으니, 다시 리액트로 돌아와서 무한스크롤링을 마저 구현해보겠습니다.

redux의 notes모듈에서 더 받아오기 epic을 추가해주겟습니다.

modules/notes.js

import { ajax } from "rxjs/observable/dom/ajax";
import { of } from "rxjs";
import { map, mergeMap, catchError, withLatestFrom, 
        // 잠시간의 delay를 두어 로딩뷰를 띄우기 위해 추가해줍니다.
        delay } from "rxjs/operators";
import { ofType } from "redux-observable";

...


const GET_MORE_NOTES = "notes/GET_MORE_NOTES";
const GET_MORE_NOTES_SUCCESS = "notes/GET_MORE_NOTES_SUCCESS";
const GET_MORE_NOTES_FAILURE = "notes/GET_MORE_NOTES_FAILURE";

...

export const getMoreNotes = lastId => ({
  type: GET_MORE_NOTES,
  payload: {
    lastId
  }
});

export const getMoreNotesSuccess = ({ notes, isLast }) => ({
  type: GET_MORE_NOTES_SUCCESS,
  payload: {
    notes,
    isLast
  }
});

export const getMoreNotesFailure = error => ({
  type: GET_MORE_NOTES_FAILURE,
  payload: {
    error
  }
});

....


const getMoreNotesEpic = (action$, state$) => {
  return action$.pipe(
    ofType(GET_MORE_NOTES),
    // 0.75초간의 딜레이를 줍니다.
    delay(750),
    withLatestFrom(state$),
    mergeMap(([action, state]) => {
      const token = localStorage.getItem("userInfo")
        ? JSON.parse(localStorage.getItem("userInfo")).token
        : null;
      const { lastId } = action.payload;
      return ajax
        .get(`/api/notes/next/${lastId}/`, {
          "Content-Type": "application/json",
          Authorization: `token ${token}`
        })
        .pipe(
          map(response => {
            const { notes, isLast } = response.response;
            return getMoreNotesSuccess({ notes, isLast });
          }),
          catchError(error =>
            of({
              type: GET_MORE_NOTES_FAILURE,
              payload: error,
              error: true
            })
          )
        );
    })
  );
};

...


const initialState = {
  noteInput: "",
  notes: [],
  error: {
    triggered: false,
    message: ""
  },
  editing: {
    id: null,
    text: ""
  },
  // 아래 추가.
  isLast: false,
  isLoading: false
};

...

case GET_MORE_NOTES:
      return {
        ...state,
        isLoading: true
      };
case GET_MORE_NOTES_SUCCESS:
  return {
    ...state,
    notes: state.notes.concat(action.payload.notes),
    isLast: action.payload.isLast,
    isLoading: false
  };
case GET_MORE_NOTES_FAILURE:
  return {
    ...state,
    error: {
      triggered: true,
      message: "ERROR WHILE LOAD MORE, TRY AGAIN"
    }
  };

...

export const notesEpics = {
  addNoteEpic,
  getNotesEpic,
  updateNoteEpic,
  deleteNoteEpic,
  getMoreNotesEpic
};

modules/index.js

import { notes, notesEpics } from "./notes";
import { auth, authEpics } from "./auth";
import { combineReducers } from "redux";
import { combineEpics } from "redux-observable";

export const rootReducers = combineReducers({ notes, auth });
export const rootEpics = combineEpics(
  notesEpics.addNoteEpic,
  notesEpics.getNotesEpic,
  notesEpics.updateNoteEpic,
  notesEpics.deleteNoteEpic,
  authEpics.loginEpic,
  authEpics.registerEpic,
  authEpics.checkUserEpic,
  authEpics.logoutEpic,
  notesEpics.getMoreNotesEpic
);

containers/NoteContainer.js


....

handleScroll = () => {
  const { innerHeight } = window;
  const { scrollHeight } = document.body;
  const scrollTop =
    (document.documentElement && document.documentElement.scrollTop) ||
    document.body.scrollTop;
  if (scrollHeight - innerHeight - scrollTop < 100) {
    if (!this.props.isLoading && !this.props.isLast) {
      const lastId = this.props.notes[this.props.notes.length - 1].id;
      this.props.getMoreNotes({ lastId });
    }
  }
};

.....

const mapStateToProps = state => ({
  noteInput: state.notes.noteInput,
  notes: state.notes.notes,
  error: state.notes.error,
  editing: state.notes.editing,
  // 아래 추가.
  isLast: state.notes.isLast,
  isLoading: state.notes.isLoading
});

const mapDispatchToProps = dispatch => {
  return {
    changeNoteInput: ({ value }, isEditing) => {
      dispatch(noteActions.changeNoteInput({ value }, isEditing));
    },
    addNote: () => {
      dispatch(noteActions.addNote());
    },
    getNotes: () => {
      dispatch(noteActions.getNotes());
    },
    toggleNote: ({ id, text }) => {
      dispatch(noteActions.toggleNote({ id, text }));
    },
    updateNote: () => {
      dispatch(noteActions.updateNote());
    },
    deleteNote: ({ id }) => {
      dispatch(noteActions.deleteNote({ id }));
    },
    // 아래 추가.
    getMoreNotes: ({lastId}) => {
      dispatch(noteActions.getMoreNotes({lastId}));
    }
  };
};
....

이렇게 되면 바닥에 닿았을때, 0.75초 간격으로 가져오게 됩니다.

중복 받아오기를 방지하기 위하여 isLoading이거나, isLast일때에는 받아오지 않게 설정해놓았습니다.

자 이제 로딩 뷰를 보여줘야겠죠?

$ yarn add better-react-spinkit

위와같은 모듈을 받아줍니다.

잘 만들어진 로딩뷰를 보여주는 모듈입니다.

그리고 LoadingView 컴포넌트를 다음과 같이 만들어줍니다.

components/notes/LoadingView/LoadingView.js

import React from "react";
import styles from "./LoadingView.scss";
import classNames from "classnames/bind";
import { ChasingDots } from "better-react-spinkit";

const cx = classNames.bind(styles);

const LoadingView = ({ isLoading }) => {
  if (!isLoading) return null;
  return (
    <div className={cx("loading-view")}>
      <ChasingDots color={"black"} size={60} />
    </div>
  );
};

export default LoadingView;

components/notes/LoadingView/LoadingView.scss

@import "utils";

.loading-view {
  display: flex;
  justify-content: center;
  align-items: center;

  padding-top: 1rem;
  padding-bottom: 1rem;
}

components/notes/LoadingView/index.js

export { default } from './LoadingView';

이 컴포넌트를 컨테이너에 반영합니다.

containers/NoteContainer.js

...
import LoadingView from "components/notes/LoadingView";

...


render() {
  const { noteInput, error, notes, editing, isLoading } = this.props;
  const {
    handleChange,
    addNote,
    handleToggle,
    updateNote,
    deleteNote
  } = this;
  return (
    <div>
      <NoteWrapper>
        <InsertForm
          noteInput={noteInput}
          onChangeInput={handleChange}
          onAdd={addNote}
          error={error}
        />
        <NoteList
          notes={notes}
          editing={editing}
          onToggle={handleToggle}
          onChange={handleChange}
          onUpdate={updateNote}
          onDelete={deleteNote}
        />
        <LoadingView isLoading={isLoading} />
      </NoteWrapper>
    </div>
  );
}
...

오 이제 로딩뷰도 보여지고, 무한 스크롤링이 아주 잘됩니다.

근데 한가지 문제점이 남아있죠~ 바로 isLast가 아닌 상태에서 즉, 10개만 받아왔다고 가정하고, 나머지 10개가 남아있다는 상황에서 위에서부터 삭제를 하면 10개는 모두 사라지지만, 나머지 10개가 받아와지지 않습니다.

이 문제를 해결하기 위해 다음과 같이 변경해줍니다.

containers/NoteContainer.js

deleteNote = ({ id }) => {
  const { deleteNote } = this.props;
  deleteNote({ id });

  const scrollHeight =
    (document.documentElement && document.documentElement.scrollHeight) ||
    document.body.scrollHeight;
  const clientHeight =
    (document.documentElement && document.documentElement.clientHeight) ||
    document.body.clientHeight;
  const offsetFlag = scrollHeight - clientHeight < 100;
  if (offsetFlag) {
    const lastId = this.props.notes[this.props.notes.length - 1].id;
    if (!this.props.isLast) {
      this.props.getMoreNotes({ lastId });
    }
  }
};

지웠을때, 남아있는 스크롤의 높이가 100보다 작아지면 만약 isLast가 아니라면 더 받아오는 형식입니다.

그러나 이렇게 했을때에도 버그가 있습니다.

엄청나게 빨리 눌렀을때에 로딩이 되는도중에도 삭제가 진행되기 때문인데요.

이 버그를 고쳐보겠습니다.

containers/NoteContainer.js

deleteNote = ({ id }) => {
  const { deleteNote } = this.props;
  // 현재 props의 isLoading이 아닐때만 지워줍니다.
  if (!this.props.isLoading) {
    deleteNote({ id });
  }

  const scrollHeight =
    (document.documentElement && document.documentElement.scrollHeight) ||
    document.body.scrollHeight;
  const clientHeight =
    (document.documentElement && document.documentElement.clientHeight) ||
    document.body.clientHeight;
  const offsetFlag = scrollHeight - clientHeight < 100;
  if (offsetFlag) {
    const lastId = this.props.notes[this.props.notes.length - 1].id;
    if (!this.props.isLast) {
      this.props.getMoreNotes({ lastId });
    }
  }
};

유후 이제 버그도 고쳤고 무한스크롤링도 되고 삭제시에도 더 많은 노트들을 보여줄수 있습니다!