4

I have a list of N JSliders (N does not change procedurally, only as I add more features. Currently N equals 4). The sum of all the sliders values must equal to 100. As one slider moves the rest of the sliders shall adjust. Each slider has values that range from 0 to 100.

Currently I am using this logic when a slider is changed (pseudo-code):

newValue = currentSlider.getValue

otherSliders = allSliders sans currentSlider
othersValue = summation of otherSliders values
properOthersValue = 100 - newValue

ratio = properOthersValue / othersValue

for slider in  otherSlider 
    slider.value = slider.getValue * ratio

The problem with this setup is slider's values are stored as ints. So as I adjust the sliders I get precision problems: sliders will twitch or not move at all depending on the ratio value. Also the total value does not always add up to 100.

Does anyone have a solution to this problem without creating an entirely new JSlider class that supports floats or doubles?

If you want an example of the behavior I want, visit: Humble Indie Bundle and scroll to the bottom of the page.

thank you

p.s. Multiplying the values by the ratio allows for the user to 'lock' values at 0. However, I am not sure what to do when 3 of the 4 sliders are at 0 and the 4th slider is at 100 and I move the 4th slider down. Using the logic above, the 3 sliders with 0 as their value stay put and the 4th slider moves to where the user puts it, which makes the total less than 100, which is improper behavior.

EDIT

Here is the SSCCE:

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.*;
import java.util.LinkedList;

public class SliderDemo
{
    static LinkedList<JSlider> sliders = new LinkedList<JSlider>();

    static class SliderListener implements ChangeListener
    {
        boolean updating = false;

        public void stateChanged(ChangeEvent e)
        {
            if (updating) return;
            updating = true;

            JSlider source = (JSlider)e.getSource();

            int newValue = source.getValue();
            LinkedList<JSlider> otherSliders = new LinkedList<JSlider>(sliders);
            otherSliders.remove(source);

            int otherValue = 0;
            for (JSlider slider : otherSliders)
            {
                otherValue += slider.getValue();
            }

            int properValue = 100 - newValue;
            double ratio = properValue / (double)otherValue;

            for (JSlider slider : otherSliders)
            {
                int currentValue = slider.getValue();
                int updatedValue = (int) (currentValue * ratio);
                slider.setValue(updatedValue);
            }

            int total = 0;
            for (JSlider slider : sliders)
            {
                total += slider.getValue();
            }
            System.out.println("Total = " + total);

            updating = false;
        }
    }

    public static void main(String[] args)
    {
        JFrame frame = new JFrame("SliderDemo");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        Container container = frame.getContentPane();
        JPanel sliderPanel = new JPanel(new GridBagLayout());
        container.add(sliderPanel);

        SliderListener listener = new SliderListener();

        GridBagConstraints gbc = new GridBagConstraints();
        gbc.gridx = 0;
        int sliderCount = 4;
        int initial = 100 / sliderCount;
        for (int i = 0; i < sliderCount; i++)
        {
            gbc.gridy = i;
            JSlider slider = new JSlider(0, 100, initial);
            slider.addChangeListener(listener);
            slider.setMajorTickSpacing(50);
            slider.setPaintTicks(true);
            sliders.add(slider);
            sliderPanel.add(slider, gbc);
        }

        frame.pack();
        frame.setVisible(true);
    }
}
reynman
  • 709
  • 3
  • 9
  • 19
  • 1
    Your best bet is to create an [SSCCE](http://sscce.org) and post the code here. Please read the link before replying because we most definitely do not want to see your whole program. – Hovercraft Full Of Eels Oct 01 '11 at 01:57
  • Please see example code of what I meant in my answer below. – Hovercraft Full Of Eels Oct 01 '11 at 03:43
  • in Swing, the answer is a custom model, always. Even though Slider doesn't honour it as much as it should: http://stackoverflow.com/questions/7468314/linked-jsliders-with-maximum-combined-value – kleopatra Oct 01 '11 at 07:42
  • After playing with this I would not recommend this kind of UI. It is terribly hard to use and get the values that you want. For example try my solution and change the values from "20, 20, 20, 20, 20" to "40, 30, 20, 10, 0". How many times did you need to manipulate the various sliders? (my best is 10). I would think a better solution would be to have the values affect only the sliders below the one you are currently changing. This way you are guaranteed to have you solution after manipulating a maximum of 4 sliders. – camickr Oct 02 '11 at 04:29
  • @kleopatra it's good advice but I don't know if I could get it working with models either – reynman Oct 03 '11 at 02:15

3 Answers3

6

Why not making the granularity of the JSlider models finer by say having them go from 0 to 1000000, and having the sum be 1000000? With the proper Dictionary for the LabelTable, the user will probably not know that it doesn't go from 0 to 100.

For example:

import java.awt.Dimension;
import java.awt.GridLayout;
import java.util.ArrayList;
import java.util.Dictionary;
import java.util.Hashtable;
import java.util.List;

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

@SuppressWarnings("serial")
public class LinkedSliders2 extends JPanel {
   private static final int SLIDER_COUNT = 5;
   public static final int SLIDER_MAX_VALUE = 1000;
   private static final int MAJOR_TICK_DIVISIONS = 5;
   private static final int MINOR_TICK_DIVISIONS = 20;
   private static final int LS_WIDTH = 700;
   private static final int LS_HEIGHT = 500;
   private JSlider[] sliders = new JSlider[SLIDER_COUNT];
   private SliderGroup2 sliderGroup = new SliderGroup2(SLIDER_MAX_VALUE);

   public LinkedSliders2() {
      Dictionary<Integer, JComponent> myDictionary = new Hashtable<Integer, JComponent>();
      for (int i = 0; i <= MAJOR_TICK_DIVISIONS; i++) {
         Integer key = i * SLIDER_MAX_VALUE / MAJOR_TICK_DIVISIONS;
         JLabel value = new JLabel(String.valueOf(i * 100 / MAJOR_TICK_DIVISIONS));
         myDictionary.put(key, value);
      }
      setLayout(new GridLayout(0, 1));
      for (int i = 0; i < sliders.length; i++) {
         sliders[i] = new JSlider(0, SLIDER_MAX_VALUE, SLIDER_MAX_VALUE
               / SLIDER_COUNT);
         sliders[i].setLabelTable(myDictionary );
         sliders[i].setMajorTickSpacing(SLIDER_MAX_VALUE / MAJOR_TICK_DIVISIONS);
         sliders[i].setMinorTickSpacing(SLIDER_MAX_VALUE / MINOR_TICK_DIVISIONS);
         sliders[i].setPaintLabels(true);
         sliders[i].setPaintTicks(true);
         sliders[i].setPaintTrack(true);
         sliderGroup.addSlider(sliders[i]);
         add(sliders[i]);
      }
   }

   @Override
   public Dimension getPreferredSize() {
      return new Dimension(LS_WIDTH, LS_HEIGHT);
   }

   private static void createAndShowGui() {
      LinkedSliders2 mainPanel = new LinkedSliders2();

      JFrame frame = new JFrame("LinkedSliders");
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.getContentPane().add(mainPanel);
      frame.pack();
      frame.setLocationByPlatform(true);
      frame.setVisible(true);
   }

   public static void main(String[] args) {
      SwingUtilities.invokeLater(new Runnable() {
         public void run() {
            createAndShowGui();
         }
      });
   }
}

class SliderGroup2 {
   private List<BoundedRangeModel> sliderModelList = new ArrayList<BoundedRangeModel>();
   private ChangeListener changeListener = new SliderModelListener();
   private int maxValueSum;

   public SliderGroup2(int maxValueSum) {
      this.maxValueSum = maxValueSum;
   }

   public void addSlider(JSlider slider) {
      BoundedRangeModel model = slider.getModel();
      sliderModelList.add(model);
      model.addChangeListener(changeListener);
   }

   private class SliderModelListener implements ChangeListener {
      private boolean internalChange = false;

      @Override
      public void stateChanged(ChangeEvent cEvt) {
         if (!internalChange) {
            internalChange = true;
            BoundedRangeModel sourceModel = (BoundedRangeModel) cEvt.getSource();
            int sourceValue = sourceModel.getValue();

            int oldSumOfOtherSliders = 0;
            for (BoundedRangeModel model : sliderModelList) {
               if (model != sourceModel) {
                  oldSumOfOtherSliders += model.getValue();
               }
            }
            if (oldSumOfOtherSliders == 0) {
               for (BoundedRangeModel model : sliderModelList) {
                  if (model != sourceModel) {
                     model.setValue(1);
                  }
               }
               internalChange = false;
               return;
            }

            int newSumOfOtherSliders = maxValueSum - sourceValue;

            for (BoundedRangeModel model : sliderModelList) {
               if (model != sourceModel) {
                  long newValue = ((long) newSumOfOtherSliders * model
                        .getValue()) / oldSumOfOtherSliders;
                  model.setValue((int) newValue);
               }
            }

            int total = 0;
            for (BoundedRangeModel model : sliderModelList) {
               total += model.getValue();
            }
            //!! System.out.printf("Total = %.0f%n", (double)total * 100 / LinkedSliders2.SLIDER_MAX_VALUE);

            internalChange = false;
         }
      }

   }

}

Edited to have SliderGroup2 use a List of BoundedRangeModels rather than JSliders.

Hovercraft Full Of Eels
  • 283,665
  • 25
  • 256
  • 373
  • An appealing idea, but a slider models an analog device having a resolution no greater than that provided by the mouse. – trashgod Oct 01 '11 at 03:41
  • @trashgod: Yep, you're right. But it seems to work somewhat OK in my SSCCE. – Hovercraft Full Of Eels Oct 01 '11 at 03:43
  • 1
    +1 Excellent! You divided the tret up evenly. `SLIDER_MAX_VALUE = 1000` and `total / 10.0` will let the arrows move the the thumbs meaningfully. I confounded resolution with precision, btw. – trashgod Oct 01 '11 at 04:09
  • 1
    @trashgod: just using 1000 rather then 1000000 is good enough to improve the accuracy of his code. **Edit:** our comments crossed paths it seems! :) – Hovercraft Full Of Eels Oct 01 '11 at 04:10
  • 1
    nice :-) Change the SliderGroup to act on BoundedRangeModel (model instead of view) and move all the calculation there to make it perfect – kleopatra Oct 01 '11 at 09:27
  • You solution is nice but it prevents the user form using the arrow keys – reynman Oct 03 '11 at 01:50
  • The arrows will work if you initialize SLIDER_MAX_VALUE at 1000 rather than 1000000. – Hovercraft Full Of Eels Oct 03 '11 at 02:20
  • @kleopatra: SliderGroup2 now uses BoundedRangeModels not JSliders. Thanks for the recommendation! – Hovercraft Full Of Eels Oct 03 '11 at 02:32
  • @Hovercraft I looked into it a little more and discovered you can remap the input and actions of keys. [Key Bindings](http://download.oracle.com/javase/tutorial/uiswing/misc/keybinding.html) Using this you can change by how much the slider moves when using the arrow keys. – reynman Oct 03 '11 at 02:55
  • @miningold: by all means, go for it. – Hovercraft Full Of Eels Oct 03 '11 at 03:10
  • Using 1000 as the max value causes some of the sliders to freeze in place when the user moves a slider left. – reynman Oct 03 '11 at 03:35
  • @miningold, how did you get the individual values of each slider to add up to 100? I was getting at total value out by as much as 4. All I did was increase the first slider to the right by one unit. – camickr Oct 03 '11 at 05:34
2

sliders will twitch or not move at all depending on the ratio value.

HumbleBundle has the same problem. If you move the slider by the keyboard then the change is only 1, which means it will only ever go to the first slider. So you ratios will eventually get out of sync.

Also the total value does not always add up to 100.

So you need to do a rounding check. If it doesn't add to 100, then you need to decide where the error goes. Maybe the last slider given the above problem?

I am not sure what to do when 3 of the 4 sliders are at 0 and the 4th slider is at 100 and I move the 4th slider down.

The way HumbleBundle handles it is to move all the slicers. However it only allows you to move the slider down increments of 3, so that you can increase each of the 3 sliders by 1.

Even the implementation at HumbleBundle isn't perfect.

camickr
  • 321,443
  • 19
  • 166
  • 288
2

Borrowing from some of Hovercrafts solution I came up with a different approach. The basis of this approach is that the "other sliders" values are tracked at the time a slider is moved. As long as you continue to slide the same slider the frozen values are used to calculate the new values. Any rounding differences are then applied sequentially to each slider until the difference is used up. Using this approach you can have incremental changes in the slider applied evenly to all of the other sliders.

The values in the model are the actual values of the slider and you can also use the keyboard to adjust the sliders:

import java.awt.*;
import java.awt.GridLayout;
import java.util.ArrayList;
import java.util.List;

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;


public class SliderGroup implements ChangeListener
{
    private List<JSlider> sliders = new ArrayList<JSlider>();
    private int groupSum;

    private boolean internalChange = false;
    private JSlider previousSlider;
    private List<SliderInfo> otherSliders = new ArrayList<SliderInfo>();

    public SliderGroup(int groupSum)
    {
        this.groupSum = groupSum;
    }

    public void addSlider(JSlider slider)
    {
        sliders.add(slider);
        slider.addChangeListener(this);
    }

    @Override
    public void stateChanged(ChangeEvent e)
    {
        if (internalChange) return;

        internalChange = true;
        JSlider sourceSlider = (JSlider)e.getSource();

        if (previousSlider != sourceSlider)
        {
            setupForSliding(sourceSlider);
            previousSlider = sourceSlider;
        }

        int newSumOfOtherSliders = groupSum - sourceSlider.getValue();
        int oldSumOfOtherSliders = 0;

        for (SliderInfo info : otherSliders)
        {
            JSlider slider = info.getSlider();

            if (slider != sourceSlider)
            {
                oldSumOfOtherSliders += info.getValue();
            }
        }

        int difference = newSumOfOtherSliders - oldSumOfOtherSliders;

        if (oldSumOfOtherSliders == 0)
        {
            resetOtherSliders( difference / otherSliders.size() );
            allocateDifference(difference % otherSliders.size(), true);
            internalChange = false;
            return;
        }

        double ratio = (double)newSumOfOtherSliders / oldSumOfOtherSliders;

        for (SliderInfo info : otherSliders)
        {
                JSlider slider = info.getSlider();
                int oldValue = info.getValue();
                int newValue = (int)Math.round(oldValue * ratio);
                difference += oldValue - newValue;
                slider.getModel().setValue( newValue );
        }

        if (difference != 0)
        {
            allocateDifference(difference, false);
        }

        internalChange = false;
    }

    private void allocateDifference(int difference, boolean adjustZeroValue)
    {
        while (difference != 0)
        {
            for (SliderInfo info : otherSliders)
            {
                if (info.getValue() != 0 || adjustZeroValue)
                {
                    JSlider slider = info.getSlider();

                    if (difference > 0)
                    {
                        slider.getModel().setValue( slider.getValue() + 1 );
                        difference--;
                    }

                    if (difference < 0)
                    {
                        slider.getModel().setValue( slider.getValue() - 1 );
                        difference++;
                    }
                }
            }
        }
    }

    private void resetOtherSliders(int resetValue)
    {
        for (SliderInfo info : otherSliders)
        {
            JSlider slider = info.getSlider();
            slider.getModel().setValue( resetValue );
        }
    }

    private void setupForSliding(JSlider sourceSlider)
    {
        otherSliders.clear();

        for (JSlider slider: sliders)
        {
            if (slider != sourceSlider)
            {
                otherSliders.add( new SliderInfo(slider, slider.getValue() ) );
            }
        }
    }

    class SliderInfo
    {
        private JSlider slider;
        private int value;

        public SliderInfo(JSlider slider, int value)
        {
            this.slider = slider;
            this.value = value;
        }

        public JSlider getSlider()
        {
            return slider;
        }

        public int getValue()
        {
            return value;
        }
    }


    private static JPanel createSliderPanel(int groupSum, int sliderCount)
    {
        int sliderValue = groupSum / sliderCount;

        SliderGroup sg = new SliderGroup(groupSum);

        JPanel panel = new JPanel( new BorderLayout() );

        JPanel sliderPanel = new JPanel( new GridLayout(0, 1) );
        panel.add(sliderPanel, BorderLayout.CENTER);

        JPanel labelPanel = new JPanel( new GridLayout(0, 1) );
        panel.add(labelPanel, BorderLayout.EAST);

        for (int i = 0; i < sliderCount; i++)
        {
            JLabel label = new JLabel();
            label.setText( Integer.toString(sliderValue) );
            labelPanel.add( label );

            JSlider slider = new JSlider(0, groupSum, sliderValue);
            slider.setMajorTickSpacing(25);
            slider.setMinorTickSpacing(5);
            slider.setPaintTicks(true);
            slider.setPaintLabels(true);
            slider.setPaintTrack(true);
            slider.addChangeListener( new LabelChangeListener(label) );
            sliderPanel.add( slider );

            sg.addSlider( slider );
        }

        return panel;
    }

    static class LabelChangeListener implements ChangeListener
    {
        private JLabel label;

        public LabelChangeListener(JLabel label)
        {
            this.label = label;
        }

        @Override
        public void stateChanged(ChangeEvent e)
        {
            JSlider slider = (JSlider)e.getSource();
            label.setText( Integer.toString(slider.getValue()) );
        }
    }

    private static void createAndShowGui()
    {
        JPanel panel = createSliderPanel(100, 5);

        JFrame frame = new JFrame("SliderGroup");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.add(panel);
        frame.pack();
        frame.setLocationByPlatform(true);
        frame.setVisible(true);
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                createAndShowGui();
            }
        });
    }
}
camickr
  • 321,443
  • 19
  • 166
  • 288
  • Your example does not always add up to 100. If the first slider is 0 and the bottom 3 sliders are 26, then the second from top slider can be moved from 22 to 23 without any of the other sliders changing – reynman Oct 03 '11 at 01:47
  • It was updating the first slider even though it had an initial value of zero which was a problem. – camickr Oct 03 '11 at 05:26