--- old/modules/javafx.controls/src/main/java/javafx/scene/control/TabPane.java 2017-09-18 18:02:34.356011000 +0530 +++ new/modules/javafx.controls/src/main/java/javafx/scene/control/TabPane.java 2017-09-18 18:02:34.200011000 +0530 @@ -848,4 +848,84 @@ */ UNAVAILABLE } + + /****************** + * TabDragPolicy * + ******************/ + private ObjectProperty tabDragPolicy; + + /** + * Sets the {@link TabDragPolicy tab drag policy} property of the TabPane. + * {@link TabDragPolicy#FIXED TabDragPolicy.FIXED} is the default + * {@link TabDragPolicy tab drag policy} of TabPane. + * This can later be retrieved by calling {@link TabPane#getTabDragPolicy()}. + * + * @param value The tab drag policy + * @see TabDragPolicy + * @since 10 + */ + public final void setTabDragPolicy(TabDragPolicy value) { + tabDragPolicyProperty().set(value); + } + + /** + * Get the current {@link TabDragPolicy tab drag policy} of the TabPane. + * {@link TabDragPolicy#FIXED TabDragPolicy.FIXED} is the default + * {@link TabDragPolicy tab drag policy} of TabPane. + * + * @return The current {@link TabDragPolicy tab drag policy} of the TabPane. + * @see TabDragPolicy + * @since 10 + */ + public final TabDragPolicy getTabDragPolicy() { + return tabDragPolicyProperty().get(); + } + + /** + * The tab drag policy property of TabPane. + * {@link TabDragPolicy#FIXED TabDragPolicy.FIXED} is the default + * {@link TabDragPolicy tab drag policy} of TabPane. + * + * @return The {@link TabDragPolicy tab drag policy} property + * @see TabDragPolicy + * @since 10 + */ + public final ObjectProperty tabDragPolicyProperty() { + if (tabDragPolicy == null) { + tabDragPolicy = new SimpleObjectProperty(this, "tabDragPolicy", TabDragPolicy.FIXED); + } + return tabDragPolicy; + } + + /** + *

This enum specifies reordering policies of the tabs from an end-users + * perspective.

+ *

The policy can be changed dynamically. The policies are:

+ * + *
    + *
  • {@link TabDragPolicy#FIXED TabDragPolicy.FIXED}: Tabs can not be dragged + * to reorder its position.
  • + * + *
  • {@link TabDragPolicy#REORDER TabDragPolicy.REORDER}: The tabs can + * be dragged to reorder within the same TabPane. User can perform the + * simple mouse press-drag-release gesture on a tab header to drag to a + * new position. A tab can not be detached from its parent TabPane.
  • + *
+ * + * @since 10 + * @see #setTabDragPolicy(TabDragPolicy) + */ + public enum TabDragPolicy { + /** + * Tabs can not be dragged to reorder its position. + */ + FIXED, + + /** + * The tabs can be dragged to reorder within the same TabPane. User can + * perform the simple mouse press-drag-release gesture on a tab header to + * drag to a new position. A tab can not be detached from its parent TabPane. + */ + REORDER + } } --- old/modules/javafx.controls/src/main/java/javafx/scene/control/skin/TabPaneSkin.java 2017-09-18 18:02:34.824011000 +0530 +++ new/modules/javafx.controls/src/main/java/javafx/scene/control/skin/TabPaneSkin.java 2017-09-18 18:02:34.668011000 +0530 @@ -33,6 +33,7 @@ import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; +import javafx.animation.Transition; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.WeakInvalidationListener; @@ -51,6 +52,7 @@ import javafx.css.StyleableProperty; import javafx.event.ActionEvent; import javafx.event.EventHandler; +import javafx.geometry.Bounds; import javafx.geometry.HPos; import javafx.geometry.Pos; import javafx.geometry.Side; @@ -68,6 +70,7 @@ import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TabPane.TabClosingPolicy; +import javafx.scene.control.TabPane.TabDragPolicy; import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; import javafx.scene.effect.DropShadow; @@ -858,6 +861,7 @@ snapSize(getWidth()) - getScrollOffset() : getScrollOffset(); updateHeaderClip(); + for (Node node : getChildren()) { TabHeaderSkin tabHeader = (TabHeaderSkin)node; @@ -873,18 +877,22 @@ if (tabPosition.equals(Side.LEFT) || tabPosition.equals(Side.BOTTOM)) { // build from the right tabX -= tabHeaderPrefWidth; - tabHeader.relocate(tabX, startY); + if (tabHeader != dragTabHeader && tabHeader != dropAnimHeader) { + tabHeader.relocate(tabX, startY); + } } else { // build from the left - tabHeader.relocate(tabX, startY); + if (tabHeader != dragTabHeader && tabHeader != dropAnimHeader) { + tabHeader.relocate(tabX, startY); + } tabX += tabHeaderPrefWidth; } } } - }; headersRegion.getStyleClass().setAll("headers-region"); headersRegion.setClip(headerClip); + setupReordering(headersRegion); headerBackground = new StackPane(); headerBackground.getStyleClass().setAll("tab-header-background"); @@ -899,6 +907,7 @@ if (controlButtons.isVisible()) { controlButtons.setVisible(true); } + getChildren().addAll(headerBackground, headersRegion, controlButtons); // support for mouse scroll of header area (for when the tabs exceed @@ -1234,6 +1243,7 @@ setId(tab.getId()); setStyle(tab.getStyle()); setAccessibleRole(AccessibleRole.TAB_ITEM); + setViewOrder(1); this.tab = tab; clip = new Rectangle(); @@ -1275,6 +1285,7 @@ behavior.closeTab(tab); setOnMousePressed(null); } + me.consume(); } }); @@ -1761,7 +1772,7 @@ Side tabPosition = getSkinnable().getSide(); downArrow.setRotate(tabPosition.equals(Side.BOTTOM)? 180.0F : 0.0F); }); - tabPane.getTabs().addListener((ListChangeListener) c -> setupPopupMenu()); + headersRegion.getChildren().addListener((ListChangeListener) c -> setupPopupMenu()); showControlButtons = false; if (isShowTabsMenu()) { showControlButtons = true; @@ -1849,7 +1860,9 @@ popup.getItems().clear(); ToggleGroup group = new ToggleGroup(); ObservableList menuitems = FXCollections.observableArrayList(); - for (final Tab tab : getSkinnable().getTabs()) { + for (int i = 0; i < headersRegion.getChildren().size(); i++) { + TabHeaderSkin tabHeader = (TabHeaderSkin)headersRegion.getChildren().get(i); + Tab tab = tabHeader.getTab(); TabMenuItem item = new TabMenuItem(tab); item.setToggleGroup(group); item.setOnAction(t -> getSkinnable().getSelectionModel().select(tab)); @@ -1912,4 +1925,308 @@ default: return super.queryAccessibleAttribute(attribute, parameters); } } -} + + // -------------------------- + // Tab Reordering + // -------------------------- + private enum DragState { + NONE, + START, + REORDER + } + private EventHandler headerDraggedHandler = this::handleHeaderDragged; + private EventHandler headerMousePressedHandler = this::handleHeaderMousePressed; + private EventHandler headerMouseReleasedHandler = this::handleHeaderMouseReleased; + + private int dragTabHeaderIndex; + private TabHeaderSkin dragTabHeader; + private TabHeaderSkin dropTabHeader; + private StackPane headersRegion; + private DragState dragState; + private int xLayoutDirection; + private double dragEventPrevLoc; + private int prevDragDirection = 1; + private final static int LTR = 1; + private final static int RTL = -1; + private final double DRAG_DIST_THRESHOLD = 0.66; + + // Reordering Animation + private static double ANIM_DURATION = 120; + private TabHeaderSkin dropAnimHeader; + private double dropHeaderSourceX; + private double dropHeaderTransitionX; + private final Animation dropHeaderAnim = new Transition() { + { + setInterpolator(Interpolator.EASE_BOTH); + setCycleDuration(Duration.millis(ANIM_DURATION)); + setOnFinished(event -> { + completeReordering(); + }); + } + protected void interpolate(double frac) { + dropAnimHeader.setLayoutX(dropHeaderSourceX + dropHeaderTransitionX * frac); + } + }; + private double dragHeaderDestX; + private double dragHeaderSourceX; + private double dragHeaderTransitionX; + private final Animation dragHeaderAnim = new Transition() { + { + setInterpolator(Interpolator.EASE_OUT); + setCycleDuration(Duration.millis(ANIM_DURATION)); + setOnFinished(event -> { + resetDrag(); + }); + } + protected void interpolate(double frac) { + dragTabHeader.setLayoutX(dragHeaderSourceX + dragHeaderTransitionX * frac); + } + }; + // Helper methods for managing the listeners based on TabDragPolicy. + private void addReorderListeners(Node n) { + n.addEventHandler(MouseEvent.MOUSE_PRESSED, headerMousePressedHandler); + n.addEventHandler(MouseEvent.MOUSE_RELEASED, headerMouseReleasedHandler); + n.addEventHandler(MouseEvent.MOUSE_DRAGGED, headerDraggedHandler); + } + + private void removeReorderListeners(Node n) { + n.removeEventHandler(MouseEvent.MOUSE_PRESSED, headerMousePressedHandler); + n.removeEventHandler(MouseEvent.MOUSE_RELEASED, headerMouseReleasedHandler); + n.removeEventHandler(MouseEvent.MOUSE_DRAGGED, headerDraggedHandler); + } + + private ListChangeListener childListener = new ListChangeListener() { + public void onChanged(Change change) { + while (change.next()) { + if (change.wasAdded()) { + for(Node n : change.getAddedSubList()) { + addReorderListeners(n); + } + } + if (change.wasRemoved()) { + for(Node n : change.getRemoved()) { + removeReorderListeners(n); + } + } + } + } + }; + + private void updateListeners() { + if (getSkinnable().getTabDragPolicy() == TabDragPolicy.FIXED || + getSkinnable().getTabDragPolicy() == null) { + for (Node n : headersRegion.getChildren()) { + removeReorderListeners(n); + } + headersRegion.getChildren().removeListener(childListener); + } else if (getSkinnable().getTabDragPolicy() == TabDragPolicy.REORDER) { + for (Node n : headersRegion.getChildren()) { + addReorderListeners(n); + } + headersRegion.getChildren().addListener(childListener); + } + } + + private void setupReordering(StackPane headerRegion) { + dragState = DragState.NONE; + headersRegion = headerRegion; + updateListeners(); + getSkinnable().tabDragPolicyProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue != newValue) { + updateListeners(); + } + }); + } + + private void handleHeaderMousePressed(MouseEvent event) { + startDrag(event); + } + + private void handleHeaderMouseReleased(MouseEvent event) { + stopDrag(); + event.consume(); + } + + private void handleHeaderDragged(MouseEvent event) { + int dragDirection; + double dragHeaderNewLayoutX; + Bounds dragHeaderBounds; + Bounds dropHeaderBounds; + double draggedDist; + double mouseCurrentLoc = deriveDragEventLoc(event); + double dragDelta = mouseCurrentLoc - dragEventPrevLoc; + + // Stop dropHeaderAnim if direction of drag is changed + if (dragDelta > 0) { + dragDirection = LTR; + } else { + dragDirection = RTL; + } + if (prevDragDirection != dragDirection) { + stopAnim(dropHeaderAnim); + prevDragDirection = dragDirection; + } + + dragHeaderNewLayoutX = dragTabHeader.getLayoutX() + xLayoutDirection * dragDelta; + + if (dragHeaderNewLayoutX >= 0 && + dragHeaderNewLayoutX + dragTabHeader.getWidth() <= headersRegion.getWidth()) { + + dragState = DragState.REORDER; + dragTabHeader.setLayoutX(dragHeaderNewLayoutX); + dragHeaderBounds = dragTabHeader.getBoundsInParent(); + + if (dragDirection == LTR) { + // Dragging the tab header towards right + // Last tab header can not be dragged towards right. + // When the mouse is moved too fast, sufficient number of events + // are not generated. Hence it is required to check all possible + // headers to be reordered. + for (int i = dragTabHeaderIndex + 1; i < headersRegion.getChildren().size(); i++) { + dropTabHeader = (TabHeaderSkin) headersRegion.getChildren().get(i); + + // Check if the tab header is already reordering. + if (dropAnimHeader != dropTabHeader) { + dropHeaderBounds = dropTabHeader.getBoundsInParent(); + + if (xLayoutDirection == LTR) { + draggedDist = dragHeaderBounds.getMaxX() - dropHeaderBounds.getMinX(); + } else { + draggedDist = dropHeaderBounds.getMaxX() - dragHeaderBounds.getMinX(); + } + + // A tab is reordered when dragged tab corsses DRAG_DIST_THRESHOLD% of next tabs width. + if (draggedDist > dropHeaderBounds.getWidth() * DRAG_DIST_THRESHOLD) { + stopAnim(dropHeaderAnim); + // Distance by which tab header should be animated in X. + dropHeaderTransitionX = xLayoutDirection * -dragHeaderBounds.getWidth(); + if (xLayoutDirection == LTR) { + dragHeaderDestX = dropHeaderBounds.getMaxX() - dragHeaderBounds.getWidth(); + } else { + dragHeaderDestX = dropHeaderBounds.getMinX(); + } + startReordering(); + } else { + break; + } + } + } + } else { + // Dragging the tab header towards left + // First tab header can not be dragged towards left. + // When the mouse is moved too fast, sufficient number of events + // are not generated. Hence it is required to check all possible + // headers to be reordered. + for (int i = dragTabHeaderIndex - 1; i >= 0; i--) { + dropTabHeader = (TabHeaderSkin) headersRegion.getChildren().get(i); + + // Check if the tab header is already reordering. + if (dropAnimHeader != dropTabHeader) { + dropHeaderBounds = dropTabHeader.getBoundsInParent(); + + if (xLayoutDirection == LTR) { + draggedDist = dropHeaderBounds.getMaxX() - dragHeaderBounds.getMinX(); + } else { + draggedDist = dragHeaderBounds.getMaxX() - dropHeaderBounds.getMinX(); + } + + // A tab is reordered when dragged tab crosses DRAG_DIST_THRESHOLD% of next tabs width. + if (draggedDist > dropHeaderBounds.getWidth() * DRAG_DIST_THRESHOLD) { + stopAnim(dropHeaderAnim); + // Distance by which tab header should be animated in X position. + dropHeaderTransitionX = xLayoutDirection * dragHeaderBounds.getWidth(); + if (xLayoutDirection == LTR) { + dragHeaderDestX = dropHeaderBounds.getMinX(); + } else { + dragHeaderDestX = dropHeaderBounds.getMaxX() - dragHeaderBounds.getWidth(); + } + startReordering(); + } else { + break; + } + } + } + } + } + dragEventPrevLoc = mouseCurrentLoc; + event.consume(); + } + + private double deriveDragEventLoc(MouseEvent event) { + if (getSkinnable().getSide().equals(Side.LEFT) || + getSkinnable().getSide().equals(Side.RIGHT)) { + return event.getScreenY(); + } + return event.getScreenX(); + } + + private int deriveTabHeaderLayoutXDirection() { + if (getSkinnable().getSide().equals(Side.TOP) || + getSkinnable().getSide().equals(Side.RIGHT)) { + // TabHeaderSkin are laid out in left to right direction + return LTR; + } + // TabHeaderSkin are laid out in right to left direction + return RTL; + } + + private void startDrag(MouseEvent event) { + stopAnim(dropHeaderAnim); + stopAnim(dragHeaderAnim); + dragTabHeader = (TabHeaderSkin) event.getSource(); + if (dragTabHeader != null) { + dragState = DragState.START; + xLayoutDirection = deriveTabHeaderLayoutXDirection(); + dragEventPrevLoc = deriveDragEventLoc(event); + dragTabHeaderIndex = headersRegion.getChildren().indexOf(dragTabHeader); + dragTabHeader.setViewOrder(0); + dragHeaderDestX = dragTabHeader.getLayoutX(); + } + } + + private void stopDrag() { + if (dragState == DragState.START) { + // No drag action was performed. + resetDrag(); + return; + } + // Animate tab header being dragged to its final position. + dragHeaderSourceX = dragTabHeader.getLayoutX(); + dragHeaderTransitionX = dragHeaderDestX - dragHeaderSourceX; + dragHeaderAnim.playFromStart(); + } + + private void resetDrag() { + dragState = DragState.NONE; + dragTabHeader.setViewOrder(1); + dragTabHeader = null; + dropTabHeader = null; + headersRegion.requestLayout(); + } + + // Animate tab header being dropped-on to its new position. + private void startReordering() { + dropAnimHeader = dropTabHeader; + dropHeaderSourceX = dropAnimHeader.getLayoutX(); + dropHeaderAnim.playFromStart(); + } + + // Remove dropAnimHeader and add at the index position of dragTabHeader. + private void completeReordering() { + if (dropAnimHeader != null) { + headersRegion.getChildren().remove(dropAnimHeader); + headersRegion.getChildren().add(dragTabHeaderIndex, dropAnimHeader); + dropAnimHeader = null; + headersRegion.requestLayout(); + dragTabHeaderIndex = headersRegion.getChildren().indexOf(dragTabHeader); + } + } + + // Helper method to stop an animation. + private void stopAnim(Animation anim) { + if (anim.getStatus() == Animation.Status.RUNNING) { + anim.getOnFinished().handle(null); + anim.stop(); + } + } +} \ No newline at end of file