Description
Problem
I've never loved the way Matplotlib Slider widgets look (and per jupyter-widgets/ipywidgets#3025 (comment) I am apparently not alone). However, beyond that I think that the current way the slider widgets are drawn introduces two issue that hamper usability.
- There is no clear handle for the user to grab
- No visual feedback that you've grabbed the slider
<speculation>
More broadly I suspect that most people's expectation of what a slider UI element will look like is strongly shaped by how they look on the web. So if Matplotlib sliders look more similar to web sliders then they will be more natural for users. </speculation>
Proposed Solution
Something to the effect of this:
script with new slider definition + an example
import numpy as np
from matplotlib.widgets import SliderBase
from matplotlib import _api
import matplotlib.patches as mpatches
from matplotlib import transforms
class newSlider(SliderBase):
cnt = _api.deprecated("3.4")(property(# Not real, but close enough.
lambda self: len(self._observers.callbacks['changed'])))
observers = _api.deprecated("3.4")(property( lambda self: self._observers.callbacks['changed']))
def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None,
closedmin=True, closedmax=True, slidermin=None,
slidermax=None, dragging=True, valstep=None,
orientation='horizontal', *, initcolor='r',s=10, **kwargs):
super().__init__(ax, orientation, closedmin, closedmax,
valmin, valmax, valfmt, dragging, valstep)
if slidermin is not None and not hasattr(slidermin, 'val'):
raise ValueError(
f"Argument slidermin ({type(slidermin)}) has no 'val'")
if slidermax is not None and not hasattr(slidermax, 'val'):
raise ValueError(
f"Argument slidermax ({type(slidermax)}) has no 'val'")
self.slidermin = slidermin
self.slidermax = slidermax
valinit = self._value_in_bounds(valinit)
if valinit is None:
valinit = valmin
self.val = valinit
self.valinit = valinit
ax.axis('off')
self.dot, = ax.plot([valinit],[.5], 'o', markersize=s, color='C0')
trans = transforms.blended_transform_factory(
ax.transData, ax.transAxes)
self.rect = mpatches.Rectangle((valmin, .25), width=valinit-valmin, height=.5, transform=trans,
color='C0', alpha=0.75)
self.above_rect = mpatches.Rectangle((valinit, .25), width=valmax-valinit, height=.5, transform=trans,
color='grey', alpha=0.5)
ax.add_patch(self.rect)
ax.add_patch(self.above_rect)
self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes,
verticalalignment='center',
horizontalalignment='right')
self.valtext = ax.text(1.02, 0.5, self._format(valinit),
transform=ax.transAxes,
verticalalignment='center',
horizontalalignment='left')
self.set_val(valinit)
def _format(self, val):
"""Pretty-print *val*."""
if self.valfmt is not None:
return self.valfmt % val
else:
_, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
# fmt.get_offset is actually the multiplicative factor, if any.
return s + self._fmt.get_offset()
def _value_in_bounds(self, val):
"""Makes sure *val* is with given bounds."""
val = self._stepped_value(val)
if val <= self.valmin:
if not self.closedmin:
return
val = self.valmin
elif val >= self.valmax:
if not self.closedmax:
return
val = self.valmax
if self.slidermin is not None and val <= self.slidermin.val:
if not self.closedmin:
return
val = self.slidermin.val
if self.slidermax is not None and val >= self.slidermax.val:
if not self.closedmax:
return
val = self.slidermax.val
return val
def _update(self, event):
"""Update the slider position."""
if self.ignore(event) or event.button != 1:
return
if event.name == 'button_press_event' and event.inaxes == self.ax:
self.drag_active = True
event.canvas.grab_mouse(self.ax)
if not self.drag_active:
return
elif ((event.name == 'button_release_event') or
(event.name == 'button_press_event' and
event.inaxes != self.ax)):
self.drag_active = False
event.canvas.release_mouse(self.ax)
self.dot.set_markeredgecolor('grey')
self.dot.set_markerfacecolor('grey')
self.ax.figure.canvas.draw_idle()
return
if event.name == 'button_press_event':
self.dot.set_markeredgecolor('C0')
self.dot.set_markerfacecolor('C0')
if self.orientation == 'vertical':
val = self._value_in_bounds(event.ydata)
else:
val = self._value_in_bounds(event.xdata)
if val not in [None, self.val]:
self.set_val(val)
def set_val(self, val):
"""
Set slider value to *val*.
Parameters
----------
val : float
"""
self.dot.set_xdata([val])
self.rect.set_width(val)
self.above_rect.set_x(val)
self.valtext.set_text(self._format(val))
if self.drawon:
self.ax.figure.canvas.draw_idle()
self.val = val
if self.eventson:
self._observers.process('changed', val)
def on_changed(self, func):
"""
Connect *func* as callback function to changes of the slider value.
Parameters
----------
func : callable
Function to call when slider is changed.
The function must accept a single float as its arguments.
Returns
-------
int
Connection id (which can be used to disconnect *func*).
"""
return self._observers.connect('changed', lambda val: func(val))
def connnect_slider_to_ax(slider, ax):
t = np.arange(0.0, 1.0, 0.001)
f0 = 3
delta_f = 5.0
amp = 5
s = amp * np.sin(2 * np.pi * f0 * t)
l, = ax.plot(t, s, lw=2)
def update(val):
freq = slider.val
l.set_ydata(amp*np.sin(2*np.pi*freq*t))
# ax.figure.canvas.draw_idle()
slider.on_changed(update)
if __name__ == '__main__':
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider
# new style
fig, ax = plt.subplots()
plt.subplots_adjust(bottom=0.25)
new_slider_ax = plt.axes([0.25, 0.1, 0.65, 0.03])
sNew = newSlider(new_slider_ax, 'Freq', 0.1, 30.0,valinit=5)
connnect_slider_to_ax(sNew, ax)
fig2, ax2 = plt.subplots()
plt.subplots_adjust(bottom=0.25)
old_slider_ax = plt.axes([0.25, 0.1, 0.65, 0.03])
sOld = Slider(old_slider_ax, 'Freq', 0.1, 30.0, valinit=5)
connnect_slider_to_ax(sOld, ax2)
plt.show()
The above script will generate these two figures for comparison. On the left is my new proposed style, and on the right is the current style:
Additional context and prior art
https://www.smashingmagazine.com/2017/07/designing-perfect-slider/
https://material-ui.com/components/slider/
https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html#IntSlider