Atualizar estilo de um componente onScroll no React.js

133

Eu construí um componente no React que deveria atualizar seu próprio estilo no scroll da janela para criar um efeito de paralaxe.

O rendermétodo component se parece com o seguinte:

  function() {
    let style = { transform: 'translateY(0px)' };

    window.addEventListener('scroll', (event) => {
      let scrollTop = event.srcElement.body.scrollTop,
          itemTranslate = Math.min(0, scrollTop/3 - 60);

      style.transform = 'translateY(' + itemTranslate + 'px)');
    });

    return (
      <div style={style}></div>
    );
  }

Isso não funciona porque o React não sabe que o componente foi alterado e, portanto, o componente não é renderizado novamente.

Tentei armazenar o valor itemTranslateno estado do componente e chamar setStateo retorno de chamada de rolagem. No entanto, isso torna a rolagem inutilizável, pois é muito lenta.

Alguma sugestão de como fazer isso?

Alejandro Pérez
fonte
9
Nunca vincule um manipulador de eventos externo a um método de renderização. Os métodos de renderização (e quaisquer outros métodos personalizados dos quais você chama renderno mesmo encadeamento) devem se preocupar apenas com a lógica referente à renderização / atualização do DOM real no React. Em vez disso, como mostrado por @AustinGreco abaixo, você deve usar os métodos de ciclo de vida React fornecidos para criar e remover sua ligação de evento. Isso o torna independente dentro do componente e garante que não haja vazamentos, certificando-se de que a ligação do evento seja removida se / quando o componente que o usa estiver desmontado.
Mike Motorista

Respostas:

232

Você deve ligar o ouvinte componentDidMount, para que ele seja criado apenas uma vez. Você deve poder armazenar o estilo no estado, provavelmente o ouvinte foi a causa dos problemas de desempenho.

Algo assim:

componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
Austin Greco
fonte
26
Descobri que o evento de rolagem setState'ing inside para animação é instável. Eu tive que definir manualmente o estilo dos componentes usando refs.
Ryan Rho
1
Para que o "this" dentro do handleScroll seria apontado? No meu caso, é "janela" não componente. I acaba passando o componente como um parâmetro
Yuji
10
@yuji, você pode evitar a necessidade de passar o componente vinculando isso no construtor: this.handleScroll = this.handleScroll.bind(this)irá vincular isso handleScrollao componente, em vez de à janela.
Matt Parrilla
1
Observe que srcElement não está disponível no Firefox.
Paulin Trognon
2
não funcionou para mim, mas o que fez foi definir scrollTop paraevent.target.scrollingElement.scrollTop
George
31

Você pode passar uma função para o onScrollevento no elemento React: https://facebook.github.io/react/docs/events.html#ui-events

<ScrollableComponent
 onScroll={this.handleScroll}
/>

Outra resposta semelhante: https://stackoverflow.com/a/36207913/1255973

Con Antonakos
fonte
5
Existe algum benefício / desvantagem nesse método em relação à adição manual de um ouvinte de evento ao elemento da janela @AustinGreco mencionado?
Dennis
2
@Dennis Um benefício é que você não precisa adicionar / remover manualmente os ouvintes do evento. Embora este possa ser um exemplo simples, se você gerenciar manualmente vários ouvintes de eventos em todo o aplicativo, é fácil esquecer de removê-los adequadamente nas atualizações, o que pode levar a erros de memória. Eu sempre usaria a versão interna, se possível.
F Lekschas
4
Vale a pena notar que isso anexa um manipulador de rolagem ao componente em si, não à janela, o que é uma coisa muito diferente. @Dennis Os benefícios do onScroll são que ele é mais cross-browser e tem melhor desempenho. Se você pode usá-lo você provavelmente deveria, mas pode não ser útil em casos como o que para o OP
Beau
20

Minha solução para criar uma barra de navegação responsiva (posição: 'relativa' quando não estiver rolando e corrigida ao rolar e não na parte superior da página)

componentDidMount() {
    window.addEventListener('scroll', this.handleScroll);
}

componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScroll);
}
handleScroll(event) {
    if (window.scrollY === 0 && this.state.scrolling === true) {
        this.setState({scrolling: false});
    }
    else if (window.scrollY !== 0 && this.state.scrolling !== true) {
        this.setState({scrolling: true});
    }
}
    <Navbar
            style={{color: '#06DCD6', borderWidth: 0, position: this.state.scrolling ? 'fixed' : 'relative', top: 0, width: '100vw', zIndex: 1}}
        >

Sem problemas de desempenho para mim.

robins_
fonte
Você também pode usar um cabeçalho falso, que basicamente é apenas um espaço reservado. Então você tem seu cabeçalho fixo e, embaixo dele, seu cabeçalho falso de marcador de posição com posição: relativo.
robins_
Sem problemas de desempenho, porque isso não aborda o desafio da paralaxe na pergunta.
Juanitogan # 14/18
19

para ajudar alguém aqui que percebeu os problemas de comportamento / desempenho atrasados ​​ao usar a resposta de Austins e deseja um exemplo usando as referências mencionadas nos comentários, aqui está um exemplo que eu estava usando para alternar uma classe para um ícone de rolagem para cima / para baixo:

No método de renderização:

<i ref={(ref) => this.scrollIcon = ref} className="fa fa-2x fa-chevron-down"></i>

No método manipulador:

if (this.scrollIcon !== null) {
  if(($(document).scrollTop() + $(window).height() / 2) > ($('body').height() / 2)){
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-up');
  }else{
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-down');
  }
}

E adicione / remova seus manipuladores da mesma maneira que Austin mencionou:

componentDidMount(){
  window.addEventListener('scroll', this.handleScroll);
},
componentWillUnmount(){
  window.removeEventListener('scroll', this.handleScroll);
},

docs sobre as refs .

adrian_reimer
fonte
4
Você salvou o meu dia! Para atualizar, você realmente não precisa usar o jquery para modificar o nome da classe neste momento, porque ele já é um elemento DOM nativo. Então você poderia simplesmente fazer this.scrollIcon.className = whatever-you-want.
southp 9/03/16
2
Isso quebra solução Reagir encapsulamento embora eu ainda não tenho certeza de uma maneira de contornar isso sem comportamento vagaroso - talvez um evento de rolagem filtrada por (pelo talvez 200-250 ms) seria uma solução aqui
Jordan
Nope filtrada por rolagem evento só ajuda a tornar a rolagem suave (em um sentido não-bloqueio), mas é preciso 500ms para um segundo para que as atualizações para o estado para aplicar no DOM: /
Jordan
Eu usei essa solução também, +1. Concordo que você não precisa do jQuery: basta usar classNameou classList. Além disso, eu não precisavawindow.addEventListener() : usei o React's onScrolle é tão rápido, desde que você não atualize props / state!
Benjamin
13

Descobri que não consigo adicionar com êxito o ouvinte de eventos, a menos que seja verdadeiro assim:

componentDidMount = () => {
    window.addEventListener('scroll', this.handleScroll, true);
},
Jean-Marie Dalmasso
fonte
Está funcionando. Mas você pode entender por que temos que passar um verdadeiro booleano para esse ouvinte.
shah chaitanya
2
Em w3schools: [ w3schools.com/jsref/met_document_addeventlistener.asp] userCapture : opcional. Um valor booleano que especifica se o evento deve ser executado na fase de captura ou subida. Valores possíveis: true - o manipulador de eventos é executado na fase de captura false - Default. O manipulador de eventos é executado na fase de bolhas
Jean-Marie Dalmasso
12

Um exemplo usando classNames , React hooks useEffect , useState e styled-jsx :

import classNames from 'classnames'
import { useEffect, useState } from 'react'

const Header = _ => {
  const [ scrolled, setScrolled ] = useState()
  const classes = classNames('header', {
    scrolled: scrolled,
  })
  useEffect(_ => {
    const handleScroll = _ => { 
      if (window.pageYOffset > 1) {
        setScrolled(true)
      } else {
        setScrolled(false)
      }
    }
    window.addEventListener('scroll', handleScroll)
    return _ => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [])
  return (
    <header className={classes}>
      <h1>Your website</h1>
      <style jsx>{`
        .header {
          transition: background-color .2s;
        }
        .header.scrolled {
          background-color: rgba(0, 0, 0, .1);
        }
      `}</style>
    </header>
  )
}
export default Header
giovannipds
fonte
1
Observe que, como esse useEffect não possui um segundo argumento, ele será executado sempre que o cabeçalho for renderizado.
Jordan
2
@ Jordan você está certo! Meu erro ao escrever isso aqui. Vou editar a resposta. Muito obrigado.
giovannipds
8

com ganchos

import React, { useEffect, useState } from 'react';

function MyApp () {

  const [offset, setOffset] = useState(0);

  useEffect(() => {
    window.onscroll = () => {
      setOffset(window.pageYOffset)
    }
  }, []);

  console.log(offset); 
};
Sodium United
fonte
Exatamente o que eu precisava. Obrigado!
aabbccsmith 4/04
Esta é de longe a resposta mais eficaz e elegante de todas. Obrigado por isso.
Chigozie Orunta 20/04
Isso precisa de mais atenção, perfeito.
Anders Kitson
6

Exemplo de componente de função usando useEffect:

Nota : Você precisa remover o ouvinte de eventos retornando uma função "limpeza" em useEffect. Caso contrário, sempre que o componente for atualizado, você terá um ouvinte de rolagem de janela adicional.

import React, { useState, useEffect } from "react"

const ScrollingElement = () => {
  const [scrollY, setScrollY] = useState(0);

  function logit() {
    setScrollY(window.pageYOffset);
  }

  useEffect(() => {
    function watchScroll() {
      window.addEventListener("scroll", logit);
    }
    watchScroll();
    // Remove listener (like componentWillUnmount)
    return () => {
      window.removeEventListener("scroll", logit);
    };
  }, []);

  return (
    <div className="App">
      <div className="fixed-center">Scroll position: {scrollY}px</div>
    </div>
  );
}
Richard
fonte
Observe que, como este useEffect não possui um segundo argumento, ele será executado sempre que o componente for renderizado.
Jordan
Bom ponto. Devemos adicionar uma matriz vazia à chamada useEffect?
Richard
É isso que eu faria :)
Jordan
5

Se você estiver interessado em um componente filho que está rolando, este exemplo pode ser útil: https://codepen.io/JohnReynolds57/pen/NLNOyO?editors=0011

class ScrollAwareDiv extends React.Component {
  constructor(props) {
    super(props)
    this.myRef = React.createRef()
    this.state = {scrollTop: 0}
  }

  onScroll = () => {
     const scrollTop = this.myRef.current.scrollTop
     console.log(`myRef.scrollTop: ${scrollTop}`)
     this.setState({
        scrollTop: scrollTop
     })
  }

  render() {
    const {
      scrollTop
    } = this.state
    return (
      <div
         ref={this.myRef}
         onScroll={this.onScroll}
         style={{
           border: '1px solid black',
           width: '600px',
           height: '100px',
           overflow: 'scroll',
         }} >
        <p>This demonstrates how to get the scrollTop position within a scrollable 
           react component.</p>
        <p>ScrollTop is {scrollTop}</p>
     </div>
    )
  }
}
user2312410
fonte
1

Resolvi o problema usando e modificando variáveis ​​CSS. Dessa forma, não preciso modificar o estado do componente que causa problemas de desempenho.

index.css

:root {
  --navbar-background-color: rgba(95,108,255,1);
}

Navbar.jsx

import React, { Component } from 'react';
import styles from './Navbar.module.css';

class Navbar extends Component {

    documentStyle = document.documentElement.style;
    initalNavbarBackgroundColor = 'rgba(95, 108, 255, 1)';
    scrolledNavbarBackgroundColor = 'rgba(95, 108, 255, .7)';

    handleScroll = () => {
        if (window.scrollY === 0) {
            this.documentStyle.setProperty('--navbar-background-color', this.initalNavbarBackgroundColor);
        } else {
            this.documentStyle.setProperty('--navbar-background-color', this.scrolledNavbarBackgroundColor);
        }
    }

    componentDidMount() {
        window.addEventListener('scroll', this.handleScroll);
    }

    componentWillUnmount() {
        window.removeEventListener('scroll', this.handleScroll);
    }

    render () {
        return (
            <nav className={styles.Navbar}>
                <a href="/">Home</a>
                <a href="#about">About</a>
            </nav>
        );
    }
};

export default Navbar;

Navbar.module.css

.Navbar {
    background: var(--navbar-background-color);
}
empreendedor
fonte
1

Minha aposta aqui é usar componentes Function com novos ganchos para resolvê-lo, mas em vez de usar useEffectcomo nas respostas anteriores, acho que a opção correta seria useLayoutEffectpor um motivo importante:

A assinatura é idêntica à useEffect, mas é acionada de forma síncrona após todas as mutações do DOM.

Isso pode ser encontrado na documentação do React . Se usarmos useEffecte recarregarmos a página já rolada, a rolagem será falsa e nossa classe não será aplicada, causando um comportamento indesejado.

Um exemplo:

import React, { useState, useLayoutEffect } from "react"

const Mycomponent = (props) => {
  const [scrolled, setScrolled] = useState(false)

  useLayoutEffect(() => {
    const handleScroll = e => {
      setScrolled(window.scrollY > 0)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  ...

  return (
    <div className={scrolled ? "myComponent--scrolled" : ""}>
       ...
    </div>
  )
}

Uma possível solução para o problema pode ser https://codepen.io/dcalderon/pen/mdJzOYq

const Item = (props) => { 
  const [scrollY, setScrollY] = React.useState(0)

  React.useLayoutEffect(() => {
    const handleScroll = e => {
      setScrollY(window.scrollY)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  return (
    <div class="item" style={{'--scrollY': `${Math.min(0, scrollY/3 - 60)}px`}}>
      Item
    </div>
  )
}
Calderón
fonte
Estou curioso sobre o useLayoutEffect. Estou tentando ver o que você mencionou.
giovannipds
Se você não se importa, poderia fornecer um exemplo repo + visual disso? Eu simplesmente não conseguia reproduzir o que você mencionou como um problema useEffectaqui em comparação com useLayoutEffect.
giovannipds
Certo! https://github.com/calderon/uselayout-vs-uselayouteffect . Isso aconteceu comigo ontem com um comportamento semelhante. BTW, eu sou um novato reagir e, possivelmente, estou totalmente errado: D: D
Calderón
Na verdade, eu tenho tentado isso muitas vezes, recarregando muito e às vezes aparece cabeçalho em vermelho em vez de azul, o que significa que está aplicando .Header--scrolledclasse às vezes, mas 100% de vezes .Header--scrolledLayouté aplicado corretamente, graças a useLayoutEffect.
Calderón
Mudei o repositório
Calderón
1

Aqui está outro exemplo usando HOOKS fontAwesomeIcon e Kendo UI React
[! [Screenshot aqui] [1]] [1]

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';


const ScrollBackToTop = () => {
  const [show, handleShow] = useState(false);

  useEffect(() => {
    window.addEventListener('scroll', () => {
      if (window.scrollY > 1200) {
        handleShow(true);
      } else handleShow(false);
    });
    return () => {
      window.removeEventListener('scroll');
    };
  }, []);

  const backToTop = () => {
    window.scroll({ top: 0, behavior: 'smooth' });
  };

  return (
    <div>
      {show && (
      <div className="backToTop text-center">
        <button className="backToTop-btn k-button " onClick={() => backToTop()} >
          <div className="d-none d-xl-block mr-1">Top</div>
          <FontAwesomeIcon icon="chevron-up"/>
        </button>
      </div>
      )}
    </div>
  );
};

export default ScrollBackToTop;```


  [1]: https://i.stack.imgur.com/ZquHI.png
jso1919
fonte
Isso é incrível. Eu tive um problema no meu useEffect () alterando o estado do meu sticky da barra de navegação no scroll usando window.onscroll () ... descobri através desta resposta que window.addEventListener () e window.removeEventListener () são a abordagem correta para controlar meu sticky navbar com um componente funcional ... obrigado!
Michael
1

Atualização para uma resposta com React Hooks

Estes são dois ganchos - um para direção (para cima / para baixo / nenhum) e um para a posição real

Use assim:

useScrollPosition(position => {
    console.log(position)
  })

useScrollDirection(direction => {
    console.log(direction)
  })

Aqui estão os ganchos:

import { useState, useEffect } from "react"

export const SCROLL_DIRECTION_DOWN = "SCROLL_DIRECTION_DOWN"
export const SCROLL_DIRECTION_UP = "SCROLL_DIRECTION_UP"
export const SCROLL_DIRECTION_NONE = "SCROLL_DIRECTION_NONE"

export const useScrollDirection = callback => {
  const [lastYPosition, setLastYPosition] = useState(window.pageYOffset)
  const [timer, setTimer] = useState(null)

  const handleScroll = () => {
    if (timer !== null) {
      clearTimeout(timer)
    }
    setTimer(
      setTimeout(function () {
        callback(SCROLL_DIRECTION_NONE)
      }, 150)
    )
    if (window.pageYOffset === lastYPosition) return SCROLL_DIRECTION_NONE

    const direction = (() => {
      return lastYPosition < window.pageYOffset
        ? SCROLL_DIRECTION_DOWN
        : SCROLL_DIRECTION_UP
    })()

    callback(direction)
    setLastYPosition(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}

export const useScrollPosition = callback => {
  const handleScroll = () => {
    callback(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}
dowi
fonte
0

Para expandir a resposta de @ Austin, você deve adicionar this.handleScroll = this.handleScroll.bind(this)ao seu construtor:

constructor(props){
    this.handleScroll = this.handleScroll.bind(this)
}
componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
...

Isto dá handleScroll() acesso ao escopo adequado quando chamado do ouvinte de eventos.

Lembre-se também de que você não pode executar .bind(this)os métodos addEventListenerou removeEventListenerporque eles retornarão referências a diferentes funções e o evento não será removido quando o componente for desmontado.

nbwoodward
fonte