Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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>
  );
}
like image 620
Ryan Peschel Avatar asked Sep 16 '19 22:09

Ryan Peschel


2 Answers

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.

like image 88
Ryan Peschel Avatar answered Sep 23 '22 14:09

Ryan Peschel


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;
like image 41
Skylar Avatar answered Sep 23 '22 14:09

Skylar