Tobias Uhmann
Tobias Uhmann

Reputation: 3037

streamlit - Sync input fields

I have two input fields that shall allow user selection via

Changing one input field should update the other to keep them in sync.

enter image description here

How do you implement that behaviour with streamlit?

What I tried so far

ID selected -> update name selection box:

users = [(1, 'Jim'), (2, 'Jim'), (3, 'Jane')]
users.sort(key=lambda user: user[1])  # sort by name

selected_id = st.sidebar.number_input('ID', value=1)

options = ['%s (%d)' % (name, id) for id, name in users]
index = [i for i, user in enumerate(users) if user[0] == selected_id][0]
selected_option = st.sidebar.selectbox('Name', options, index)

Name selected -> update ID number input (using st.empty()):

users = [(1, 'Jim'), (2, 'Jim'), (3, 'Jane')]
users.sort(key=lambda user: user[1])  # sort by name

id_input = st.sidebar.empty()

options = ['%s (%d)' % (name, id) for id, name in users]
selected_option = st.sidebar.selectbox('Name', options)

# e.g. get 2 from "Jim (2)"
id = int(re.match(r'\w+ \((\d+)\)', selected_option).group(1))
selected_id = id_input.number_input('ID', value=id)

Upvotes: 2

Views: 3022

Answers (1)

ZechyW
ZechyW

Reputation: 136

To keep the widgets in sync, there are two issues that need to be addressed:

  1. We need to be able to tell when either widget has caused the current selection to change; and
  2. We need to update the state of both widgets at the end of the script so that the browser keeps the new values when the script is re-run for visual updates.

For (1), it looks like there's no way of doing it without introducing some kind of persistent state. Without a way to store the current selection between script runs, we can only compare the two widgets' values with each other and with the default value. This causes problems once the widgets have been changed: For example, if the default value is 1, the value of the number input is 2, and the value from the selectbox is 3, we cannot tell whether it is the number input or the selectbox that was most recently changed (and therefore which one to update to the latest value).

For (2), it's a simple matter of using placeholders and refreshing the widgets whenever the selection has changed. Importantly, the widgets should not be refreshed if the selection has not changed, or we will get DuplicateWidgetID errors (since the content of the widgets will not have changed either, and they will have the same generated keys).

Here's some code that shows one way of dealing with both issues and capturing the user's selection at the end. Note that using @st.cache in this way will persist a single global selection across multiple browser sessions, and will allow anyone to clear the selection via the Streamlit menu -> 'Clear cache', which could be a problem if multiple users are accessing the script at the same time.

import re

import streamlit as st

# Simple persistent state: The dictionary returned by `get_state()` will be
# persistent across browser sessions.
@st.cache(allow_output_mutation=True)
def get_state():
    return {}


# The actual creation of the widgets is done in this function.
# Whenever the selection changes, this function is also used to refresh the input
# widgets so that they reflect their new state in the browser when the script is re-run
# to get visual updates.
def display_widgets():
    users = [(1, "Jim"), (2, "Jim"), (3, "Jane")]
    users.sort(key=lambda user: user[1])  # sort by name
    options = ["%s (%d)" % (name, id) for id, name in users]
    index = [i for i, user in enumerate(users) if user[0] == state["selection"]][0]

    return (
        number_placeholder.number_input(
            "ID", value=state["selection"], min_value=1, max_value=3,
        ),
        option_placeholder.selectbox("Name", options, index),
    )


state = get_state()

# Set to the default selection
if "selection" not in state:
    state["selection"] = 1

# Initial layout
number_placeholder = st.sidebar.empty()
option_placeholder = st.sidebar.empty()

# Grab input and detect changes
selected_number, selected_option = display_widgets()

input_changed = False

if selected_number != state["selection"] and not input_changed:
    # Number changed
    state["selection"] = selected_number
    input_changed = True
    display_widgets()

selected_option_id = int(re.match(r"\w+ \((\d+)\)", selected_option).group(1))
if selected_option_id != state["selection"] and not input_changed:
    # Selectbox changed
    state["selection"] = selected_option_id
    input_changed = True
    display_widgets()

st.write(f"The selected ID was: {state['selection']}")

Upvotes: 4

Related Questions