8

I got a ScrollPane containing focusable Nodes.

The current default behaviour is:

  • Shift + , , , moves the focus

  • , , , scrolls the view

I want it the other way around. How can I accomplish this or where should I start?


[EDIT] Well, there is another fragile approach.

Instead of messing around with the events, one could mess around with the KeyBindings.

    scrollPane.skinProperty().addListener(new ChangeListener<Skin<?>>() {
        @Override
        public void changed(ObservableValue<? extends Skin<?>> observable, Skin<?> oldValue, Skin<?> newValue) {
            ScrollPaneSkin scrollPaneSkin = (ScrollPaneSkin) scrollPane.getSkin();
            ScrollPaneBehavior scrollPaneBehavior = scrollPaneSkin.getBehavior();
            try {
                Field keyBindingsField = BehaviorBase.class.getDeclaredField("keyBindings");
                keyBindingsField.setAccessible(true);
                List<KeyBinding> keyBindings = (List<KeyBinding>) keyBindingsField.get(scrollPaneBehavior);
                List<KeyBinding> newKeyBindings = new ArrayList<>();
                for (KeyBinding keyBinding : keyBindings) {
                    KeyCode code = keyBinding.getCode();
                    newKeyBindings.add(code == KeyCode.LEFT || code == KeyCode.RIGHT || code == KeyCode.UP || code == KeyCode.DOWN ? keyBinding.shift() : keyBinding);
                }
                keyBindingsField.set(scrollPaneBehavior, newKeyBindings);
            } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
                LOGGER.warn("private api changed.", e);
            }
        }
    });

I think, that could be the cleaner way, if KeyBindings were more non-static, modifyable and public.

Jens Piegsa
  • 7,399
  • 5
  • 58
  • 106

1 Answers1

6

Use an event filter to capture the relevant key events and remap them to different key events before the events start to bubble.

Re-mapping default keys is a tricky thing which:

  1. Can confuse the user.
  2. May have unexpected side effects (e.g. TextFields may no longer work as you expect).

So use with care:

import javafx.application.*;
import javafx.event.*;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.TilePane;
import javafx.stage.Stage;

import java.util.*;

public class ScrollInterceptor extends Application {

  @Override
  public void start(Stage stage) {
    ScrollPane scrollPane = new ScrollPane(
      createScrollableContent()
    );

    Scene scene = new Scene(
      scrollPane,
      300, 200
    );

    remapArrowKeys(scrollPane);

    stage.setScene(scene);
    stage.show();

    hackToScrollToTopLeftCorner(scrollPane);
  }

  private void remapArrowKeys(ScrollPane scrollPane) {
    List<KeyEvent> mappedEvents = new ArrayList<>();
    scrollPane.addEventFilter(KeyEvent.ANY, new EventHandler<KeyEvent>() {
      @Override
      public void handle(KeyEvent event) {
        if (mappedEvents.remove(event))
          return;

        switch (event.getCode()) {
          case UP:
          case DOWN:
          case LEFT:
          case RIGHT:
            KeyEvent newEvent = remap(event);
            mappedEvents.add(newEvent);
            event.consume();
            Event.fireEvent(event.getTarget(), newEvent);
        }
      }

      private KeyEvent remap(KeyEvent event) {
        KeyEvent newEvent = new KeyEvent(
            event.getEventType(),
            event.getCharacter(),
            event.getText(),
            event.getCode(),
            !event.isShiftDown(),
            event.isControlDown(),
            event.isAltDown(),
            event.isMetaDown()
        );

        return newEvent.copyFor(event.getSource(), event.getTarget());
      }
    });
  }

  private TilePane createScrollableContent() {
    TilePane tiles = new TilePane();
    tiles.setPrefColumns(10);
    tiles.setHgap(5);
    tiles.setVgap(5);
    for (int i = 0; i < 100; i++) {
      Button button = new Button(i + "");
      button.setMaxWidth(Double.MAX_VALUE);
      button.setMaxHeight(Double.MAX_VALUE);
      tiles.getChildren().add(button);
    }
    return tiles;
  }

  private void hackToScrollToTopLeftCorner(final ScrollPane scrollPane) {
    Platform.runLater(new Runnable() {
      @Override
      public void run() {
        scrollPane.setHvalue(scrollPane.getHmin());
        scrollPane.setVvalue(0);
      }
    });
  }

  public static void main(String[] args) {
    launch(args);
  }
}
jewelsea
  • 150,031
  • 14
  • 366
  • 406
  • That's exactly what I was looking for. A quick first attempt to take over the code into my application however resulted in a `StackOverflowError`. I will look at this in the evening. Thanks! – Jens Piegsa Oct 28 '13 at 07:54
  • 1
    The copyFor statement is key otherwise the code JavaFX system will internally copy the event to set the source and target, so you will end up in an endless loop of event remapping, switching the shift modifier on and off, resulting in an overflow. My test system was OS X with Java 8. – jewelsea Oct 28 '13 at 08:05
  • Somehow, in my application no events are removed from the `mappedEvents` list. Your example works fine. I could not figure out what makes the difference so far. – Jens Piegsa Oct 30 '13 at 08:00
  • 1
    Perhaps [attach the source](http://stackoverflow.com/questions/13407017/javafx-source-code-not-showing-in-intellij-idea) and step through with a debugger (that is how I determined to use the copyFor). I wish the solution was not so fragile. Alternately, you could create your own control [behaviours](https://bitbucket.org/openjfxmirrors/openjfx-8-master-rt/src/3f7db573094b0dfe9e83b6c511e8f7cae6aefb38/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/ScrollPaneBehavior.java?at=default) and skins to override the default processing, but that is some work. – jewelsea Oct 30 '13 at 08:32
  • I added another approach to my question. – Jens Piegsa Oct 31 '13 at 11:38
  • Accepted as a nevertheless helpful answer. – Jens Piegsa Oct 31 '13 at 14:22