Files
react-native/ReactCommon/fabric/components/textinput/iostextinput/TextInputShadowNode.cpp
T
Samuel Susla a40cfc05b8 Fix controlled TextInput with child nodes
Summary:
Changelog: [Internal]

# There are three changes in this diff

## _stateRevision is replaced with a BOOL
`_stateRevision` was protecting against setting attributed string that is already visible to the user. Previously this was ok because the change was only coming from native, any changes from JS were ignored.

Imagine following scenario:

1. User taps key.
2. Update state is called on component initiated by native.
3. New state is created with incremented revision by one.
4. `_stateRevision` gets set to new state's revision + 1.
5. Now JS wants to change something because it just learnt that user tapped the key.
6. New state is created again with incremented revision by one.
7. Update state is called on the component, but the change isn't applied to the text view because `_state->getRevision()` will equal `_stateRevision`.

By having a BOOL instead of number, we very explicitly mark the region in which we don't want state changes to be applied to text view.

## Calling [_backedTextInputView setAttributedText] move cursor to the end of text input
This is prevented by storing what the current selection is and applying it after `[_backedTextInputView setAttributedText]` is called.
This was previously invisible because JS wasn't changing contents of `_backedTextInputView`.

## Storing of previously applied JS attributed string in state

This is the mechanism used to detect when value of text input changes come from JavaScript. JavaScript sends text input value changes through props and as children of TextInput.
We compare what previously was set from JavaScript to what is currently being send from JavaScript and if they differ, this change is communicated to the component.
Previously only first attributed string send from JavaScript was send to the component.

# Problem

If children are used to set text input's value, then there is a case in which we can't tell what source of truth should be.

Let's take following example
We have a text field that allows only 4 characters, again this is only a problem if those 4 characters come as children, not as value.
This is a controller text input.

1. User types 1234.
2. User types 5th character.
3. JavaScript updates TextInput, saying that the content should stay 1234.
4. In `TextInputShadowNode` `hasJSUpdatedAttributedString` will be set to false, because previous JS value is the same as current JS value.

Reviewed By: shergin

Differential Revision: D20587681

fbshipit-source-id: 1b8a2efabbfa0fc87cba210570142d162efe61e6
2020-03-23 04:42:09 -07:00

109 lines
3.5 KiB
C++

/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#include "TextInputShadowNode.h"
#include <react/attributedstring/AttributedStringBox.h>
#include <react/attributedstring/TextAttributes.h>
#include <react/core/LayoutConstraints.h>
#include <react/core/LayoutContext.h>
#include <react/core/conversions.h>
namespace facebook {
namespace react {
extern char const TextInputComponentName[] = "TextInput";
AttributedStringBox TextInputShadowNode::attributedStringBoxToMeasure() const {
bool hasMeaningfulState =
getState() && getState()->getRevision() != State::initialRevisionValue;
if (hasMeaningfulState) {
auto attributedStringBox = getStateData().attributedStringBox;
if (attributedStringBox.getMode() ==
AttributedStringBox::Mode::OpaquePointer ||
!attributedStringBox.getValue().isEmpty()) {
return getStateData().attributedStringBox;
}
}
auto attributedString =
hasMeaningfulState ? AttributedString{} : getAttributedString();
if (attributedString.isEmpty()) {
auto placeholder = getConcreteProps().placeholder;
// Note: `zero-width space` is insufficient in some cases (e.g. when we need
// to measure the "hight" of the font).
auto string = !placeholder.empty() ? placeholder : "I";
auto textAttributes = getConcreteProps().getEffectiveTextAttributes();
attributedString.appendFragment({string, textAttributes, {}});
}
return AttributedStringBox{attributedString};
}
AttributedString TextInputShadowNode::getAttributedString() const {
auto textAttributes = getConcreteProps().getEffectiveTextAttributes();
auto attributedString = AttributedString{};
attributedString.appendFragment(
AttributedString::Fragment{getConcreteProps().text, textAttributes});
auto attachments = Attachments{};
BaseTextShadowNode::buildAttributedString(
textAttributes, *this, attributedString, attachments);
return attributedString;
}
void TextInputShadowNode::setTextLayoutManager(
TextLayoutManager::Shared const &textLayoutManager) {
ensureUnsealed();
textLayoutManager_ = textLayoutManager;
}
void TextInputShadowNode::updateStateIfNeeded() {
ensureUnsealed();
auto attributedStringFromJS = getAttributedString();
bool hasJSUpdatedAttributedString = false;
if (getState()) {
hasJSUpdatedAttributedString =
attributedStringFromJS.compareTextAttributesWithoutFrame(
getStateData().lastAttributedStringFromJS);
}
if (!getState() || getState()->getRevision() == State::initialRevisionValue ||
hasJSUpdatedAttributedString) {
auto state = TextInputState{};
state.attributedStringBox = AttributedStringBox{attributedStringFromJS};
state.lastAttributedStringFromJS = attributedStringFromJS;
state.paragraphAttributes = getConcreteProps().paragraphAttributes;
state.layoutManager = textLayoutManager_;
setStateData(std::move(state));
}
}
#pragma mark - LayoutableShadowNode
Size TextInputShadowNode::measure(LayoutConstraints layoutConstraints) const {
return textLayoutManager_
->measure(
attributedStringBoxToMeasure(),
getConcreteProps().getEffectiveParagraphAttributes(),
layoutConstraints)
.size;
}
void TextInputShadowNode::layout(LayoutContext layoutContext) {
updateStateIfNeeded();
ConcreteViewShadowNode::layout(layoutContext);
}
} // namespace react
} // namespace facebook