Possible to have a dynamically height adjusted textarea without constant reflows?
Asked Answered
M

5

8

Note: This is not a duplicate as far as I can tell, as using a contentEditable div doesn't seem to be a good alternative. It has numerous problems (no placeholder text, need to use the dangerouslySetInnerHTML hack to update text, selection cursor is finicky, other browser issues, etc.) I would like to use a textarea.

I'm currently doing something this for my React textarea component:

componentDidUpdate() {
  let target = this.textBoxRef.current;

  target.style.height = 'inherit';
  target.style.height = `${target.scrollHeight + 1}px`; 
}

This works and allows the textarea to dynamically grow and shrink in height as line breaks are added and removed.

The problem is that on every text change there is a reflow occurring. This causes a lot of lag in the application. If I hold down a key in the textarea there is delay and lag as the characters are appended.

If I remove the target.style.height = 'inherit'; line the lag goes away, so I know it's being caused by this constant reflow.

I heard that setting overflow-y: hidden might get rid of the constant reflow, but it did not in my case. Likewise, setting target.style.height = 'auto'; did not allow for dynamic resize.

I currently have developed a solution to this which works, but I don't like it, as it is an O(n) operation for every time the text changes. I just count the number of line breaks and set the size accordingly, like this:

// In a React Component

handleMessageChange = e => { 
  let breakCount = e.target.value.split("\n").length - 1;

  this.setState({ breakCount: breakCount });
}

render() {
  let style = { height: (41 + (this.state.breakCount * 21)) + "px" };

  return (
    <textarea onChange={this.handleMessageChange} style={style}></textarea>
  );
}
Mut answered 16/9, 2019 at 22:48 Comment(8)
Look at how any of the existing libraries do it (or use one of them). For example (demo). An important part of that is the debounce with a wait of 166ms, so it doesn't reflow constantly. And the hidden "shadow" <textarea>.Stallard
what do you mean by constant reflows?Nabors
@Nabors I mean the browser does a reflow every time the text in the textarea changes (due to accessing target.style.height)Mut
Do you find the Material-UI autosizing textbox demo in my comment to be laggy? It looks perfect to me. Therefore, if your implementation is "laggy", you may have not copied the ideas in the code in quite the same way. You should make a live demo showing your actual current code (and describe how it "lags"), use codesandbox.io or similar.Stallard
Might help to add some conditional inside your componentDidUpdate. Like only changing the height when there is a difference in the number of line breaks.Assailant
Change your listener to keyupOus
unfortunately that won't work either. for example, if you hold down a key and cause the message to go to the next line while doing so, the textarea should expand while the key is being held down. @Assailant that still involves counting the line breaks on every text change.Mut
I've stumbled upon this question again. At this point just directly use the Material-UI code. You can literally copy the single (2.1kB gzipped) file I linked to within 30 mins of your question being asked. You don't need to import Material-UI at all, if you don't want to. It makes little sense to "hack together" your own version in this way. You might be suffering from "not invented here syndrome " or "reinventing the wheel". It can be good to code it yourself to understand, but you should use the existing solution in the end.Stallard
M
8

I think thirtydot's recommendation may be the best. The Material UI textarea he linked has a pretty clever solution.

They create a hidden absolutely positioned textarea that mimics the style and width of the actual textarea. Then they insert the text you type into that textarea and retrieve the height of it. Because it is absolutely positioned there is no reflow calculation. They then use that height for the height of the actual textarea.

I don't fully understand all of what their code is doing, but I've hacked together a minimal repurposement for my needs, and it seems to work well enough. Here are some snippets:

.shadow-textarea {
  visibility: hidden;
  position: absolute;
  overflow: hidden;
  height: 0;
  top: 0;
  left: 0
}
<textarea ref={this.chatTextBoxRef} style={{ height: this.state.heightInPx + "px" }}
          onChange={this.handleMessageChange} value={this.props.value}>
</textarea>

<textarea ref={this.shadowTextBoxRef} className="shadow-textarea" />
componentDidUpdate() {
  this.autoSize();
}

componentDidMount() {
  this.autoSize();
}
autoSize = () => {
  let computedStyle = window.getComputedStyle(this.chatTextBoxRef.current); // this is fine apparently..?

  this.shadowTextBoxRef.current.style.width = computedStyle.width; // apparently width retrievals are fine
  this.shadowTextBoxRef.current.value = this.chatTextBoxRef.current.value || 'x';

  let innerHeight = this.shadowTextBoxRef.current.scrollHeight; // avoiding reflow because we are retrieving the height from the absolutely positioned shadow clone

  if (this.state.heightInPx !== innerHeight) { // avoids infinite recursive loop
    this.setState({ heightInPx: innerHeight });
  }
}

A bit hacky but it seems to work well enough. If anyone can decently improve this or clean it up with a more elegant approach I'll accept their answer instead. But this seems to be the best approach considering Material UI uses it and it is the only one I've tried so far that eliminates the expensive reflow calculations that cause lag in a sufficiently complex application.

Chrome is only reporting reflow occurring once when the height changes, as opposed to on every keypress. So there is still a single 30ms lag when the textarea grows or shrinks, but this is much better than on every key stroke or text change. The lag is 99% gone with this approach.

Mut answered 21/9, 2019 at 11:26 Comment(7)
This causes reflow multiple times when the box changes. You are correct that getting the width does not cause reflow, but appaerently changing the width of the .shadowbox does cause reflow. Getting the scrollHeight of the .shadowbox also causes reflow. (Which surprised me, too!) Activity tree: i.sstatic.net/dUk2a.png Marked code: i.sstatic.net/Llf0B.png Sandbox with your code: codesandbox.io/s/epic-leakey-lqu27Direction
I'm about to go to sleep so I can't investigate your comment right now, but something is different. Material UI uses this complicated approach for a reason. Also, all the reflow lag went away when I adopted their solution.Mut
When putting in a return character, my solution took 3.0ms and this one took 5.9ms.Direction
I tried your solution previously and it caused the same stuttering as in my original post because accessing scrollHeight on the textarea element on every text change makes the UI freeze with reflows until the user releases the key.Mut
I'll admit, I'm very confused. I didn't think the Material UI solution would work either because it also causes reflows (even multiples apparently!), but for some reason (browser idiosyncracies?) it eliminates the lag. The Material UI developers must know something that we don't. It's a mystery to me.Mut
Chrome 77 at the momentMut
Let us continue this discussion in chat.Direction
D
3

NOTE: Ryan Peschel's answer is better.

Original Post: I have heavily modified apachuilo's code to achieve the desired result. It adjusts the height based on the scrollHeight of the textarea. When the text in the box is changed, it sets the box's number of rows to the value of minRows and measures the scrollHeight. Then, it calculates the number of rows of text and changes the textarea's rows attribute to match the number of rows. The box does not "flash" while calculating.

render() is only called once, and only the rows attribute is changed.

It took about 500ms to add a character when I put in 1000000 (a million) lines of at least 1 character each. Tested it in Chrome 77.

CodeSandbox: https://codesandbox.io/s/great-cherry-x1zrz

import React, { Component } from "react";

class TextBox extends Component {
  textLineHeight = 19;
  minRows = 3;

  style = {
    minHeight: this.textLineHeight * this.minRows + "px",
    resize: "none",
    lineHeight: this.textLineHeight + "px",
    overflow: "hidden"
  };

  update = e => {
    e.target.rows = 0;
    e.target.rows = ~~(e.target.scrollHeight / this.textLineHeight);
  };

  render() {
    return (
      <textarea rows={this.minRows} onChange={this.update} style={this.style} />
    );
  }
}

export default TextBox;
Direction answered 21/9, 2019 at 1:31 Comment(5)
Unfortunately this doesn't seem much different from the first code block in the original post since this also accesses e.target.scrollHeight on every text change. You can see in this post by a senior member of the Chrome team that even just accessing this property causes reflow.Mut
I'm not sure how it could be considered impossible when the latter code solution in my original post does it, albeit inefficiently by counting line breaks.Mut
Your original code does not take into account word wrap.Direction
True, good catch, another reason why the original code solution isn't ideal either.Mut
I've posted a solution. It is indeed possible to accomplish this without causing reflow.Mut
A
3

While it's not possible to eliminate all reflows — the browser has to calculate the height at some point — it is possible to reduce them significantly.

Per Paul Irish (a Chrome developer), elem.scrollHeight is among the property accesses & methods that cause a reflow. However, there is a significant note:

Reflow only has a cost if the document has changed and invalidated the style or layout. Typically, this is because the DOM was changed (classes modified, nodes added/removed, even adding a psuedo-class like :focus).

This is where, for plain text, a textarea is actually superior to a <div contenteditable>. For a div, typing changes the innerHTML, which is actually a Text node. As such, modifying the text in any way also modifies the DOM, causing a reflow. In the case of a textarea, typing only changes its value property — nothing touches the DOM, all that's required is repainting, which is (comparatively) very cheap. This allows the rendering engine to cache the value as indicated by the above quote.

Because of the browser's cacheing of scrollHeight, you can use the "classic" advice — fetch that value and immediately set it to the actual height.

function resizeTextarea(textarea) {
    textarea.style.height = 'auto';
    textarea.style.height = `${textarea.style.scrollHeight}px`;
}

Use that method any time the value changes, which will ensure the textarea remains at a height that does not scroll. Don't worry about the consecutive setting of the property, as the browser executes these together (similar to requestAnimationFrame).

This is true in all WebKit-based browsers, which are currently Chrome and Opera, and soon to be Edge as well. I presume Firefox and Safari have similar implementations.

Annulate answered 25/9, 2019 at 17:42 Comment(0)
A
1

Personally I couldn't image reading all those line breaks like that being too much of a problem unless your writing a novel, but I don't know. You could try adjusting the number of breaks based on keystroke.

Sandbox here.

import React, { Component } from "react";

class TextBox extends Component {
  state = {
    breakCount: 0
  };

  handleKeyDown = e => {
    if (e.key === "Enter") {
      this.setState({ breakCount: this.state.breakCount + 1 });
    }

    // Note you will want something to better detect if a newline is being deleted. Could do this with more logic
    // For quick testing of spamming enter/backspace key though this works.
    if (e.key === "Backspace" && this.state.breakCount > 0) {
      this.setState({ breakCount: this.state.breakCount - 1 });
    }
  };

  render() {
    const style = { height: 41 + this.state.breakCount * 21 + "px" };

    return <textarea onKeyDown={this.handleKeyDown} style={style} />;
  }
}

export default TextBox;
Assailant answered 20/9, 2019 at 21:17 Comment(3)
What if we copy-paste text? Or autofilled?Annulate
Yeah I mean you'd have to listen for all those events and handle them accordingly (in the case of pasting having to count all the linebreaks). Honestly the best solution imo would be just denouncing and resizing.Assailant
I thought of this solution initially as well, but it suffers from having to handle a multitude of potential cases which causes the solution to be quite brittleMut
P
1

A "modern" hooks-apporach only using built-in features of react would be useRef and useLayoutEffects. This approach updates the height of the textarea triggered by the change of value before any rendering in the browser and therefor avoids any flickering/jumping of the textarea.

import React from "react";

const MIN_TEXTAREA_HEIGHT = 32;

export default function App() {
  const textareaRef = React.useRef(null);
  const [value, setValue] = React.useState("");
  const onChange = (event) => setValue(event.target.value);

  React.useLayoutEffect(() => {
    // Reset height - important to shrink on delete
    textareaRef.current.style.height = "inherit";
    // Set height
    textareaRef.current.style.height = `${Math.max(
      textareaRef.current.scrollHeight,
      MIN_TEXTAREA_HEIGHT
    )}px`;
  }, [value]);

  return (
    <textarea
      onChange={onChange}
      ref={textareaRef}
      style={{
        minHeight: MIN_TEXTAREA_HEIGHT,
        resize: "none"
      }}
      value={value}
    />
  );
}

https://codesandbox.io/s/react-textarea-auto-height-s96b2

Paugh answered 1/2, 2021 at 10:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.