Как сделать всплывающие подсказки в JavaFX

    Забытое искусство подсказки


    Давным-давно, когда люди ещё делали домашние странички, интернет был по шипучему модему, а Napster казался опасной провокацией коммунистов, простенькие оконные приложения под Windows очень часто писали на библиотеке VCL. Одни пользовались Delphi (но признавались только домашним), другие смело запускали её из C++ Builder (и удивлялись ещё одному String, а также спискам, которые начинаются с 1). А кто-то ухитрялся писать на нём под unix-ы (вы помните Kylix? А он был!)

    В VCL почти у всех визуальных компонент были свойства ShowHint и Hint. Если быть точным, они были у всех компонент оконного типа (кнопок, выпадающих списков и прочих панелек).

    В строковой Hint писался текст подсказки, а булевый ShowHint мог её отключить. А более прокачанные даже знали, что можно сделать расширенный вариант подсказки. Если написать в Hint Нажми меня|Кнопка просит, чтобы вы её нажали, то левая часть всплывёт, а правая будет передана в событие. Это событие перехватывали и показывали полученный текст в строке состояния.

    Настолько простые и удобные подсказки были предметом величайшей зависти тех, кто сидел на MFC (никаких WinForms в тогдашней Visual Studio ещё не было, не говоря о WPF), поддерживал OWL или штурмовал чистый WinAPI с Петцольдом наперевес. Кто знает, может быть кто-то из них и приложил свою мозолистую от кодинга руку к тому, что уникальная по своей удобности технология подсказок оказалась полностью утрачена в JavaFX.

    Что у нас есть?


    В JavaFX за подсказки отвечает компонент Tooltip. По всей видимости, это тайный компонент, потому что, к примеру, SceneBuilder его знать не знает.

    Если мы создадим новый Tooltip, а потом привяжем через setTooltip, то при наведении курсора на компонент, к которому привязывали, мы и правда увидим подсказку. На чёрном фоне (так надо). И с поддержкой картинок (спасибо).

    Но JavaFX не позволит нам расслабиться: свойство Tooltip (и соответствующие методы) есть только у наследников класса javafx.scene.control.Control. А все панели и прочие области наследуются от javafx.scene.layout.Region. И никаких подсказок на них всплывать, получается, не может. Видимо, раз в приложении есть панель, то пользователю должно быть и так ясно, что там находится.

    А может, разработчиков в школе слишком часто заставляли решать задачу «двумя разными способами»? Что до поддержки подсказок в панели статуса, то их нет даже близко.

    А ТЗ требовало панель состояния и подсказки в ней в том числе и на панелях. И я справился своими силами.

    Показываем и подсказываем


    Я не стал пытаться заставить Tooltip всплывать над компонентами «неправильного» типа. Я просто хотел получить простой стандартный интерфейс, чтобы привязывать к компонентам подсказки в строке состояния, И, по возможности, делать всплывающие подсказки.

    Причём интерфейс должен быть настолько простым, чтобы я мог поручить непосредственное привязывание подсказок студенту-стажёру, который работает на полставки.

    Как и положено рабочему прототипу, это решение выглядит почти тривиальным. Поэтому я исключил все комментарии, которые бы только растянули статью. Но у него есть маленькое, но преимущество: приспособить его к своему проекту намного быстрее и проще, чем писать с нуля.

    Особенно если вы такой же лентай, как и я.

    Подсказываем


    Саму подсказку мы будем хранить в двух классах, унаследованных от общего предка. Сначала создадим абстрактный класс подсказки.

    Буква A перед названием пошла из старых учебников C++, по которым я учился программировать. Я не против, если вы привыкли по-другому (например, с постфиксом Base и т. п.). Я люблю начальную A, потому что она короткая.

    И, как вы наверное догадались, любовь к шаблонам и генетикам у меня тоже из C++:

    import javafx.scene.Node;
    
    public abstract class ATooltipHintItem<N extends Node> {
        private N attachedNode;
        protected void setAttachedNode(N node) {
            attachedNode = node;
        }
        public N getAttachedNode() {
            return attachedNode;
        }
    
        private String statusBarHint;
        protected void setStatusBarHint(String hint){
            statusBarHint = hint;
        }
        public String getStatusBarHint(){
            return statusBarHint;
        }
    
        private ITooltipHintController tooltipHintController;
        public ITooltipHintController getTooltipHintController() {
            return tooltipHintController;
        }
    
        public void showStatusBarHint(){
            tooltipHintController.setStatusBarText(statusBarHint);
        }
    
        public ATooltipHintItem(N attachedNode, ITooltipHintController tooltipHintController, String statusBarHint) {
            this.attachedNode = attachedNode;
            this.tooltipHintController = tooltipHintController;
    
            if(statusBarHint != null && !statusBarHint.equals("")){
                initStatusBar();
                this.setStatusBarHint(statusBarHint);
            }
        }
    
        private void initStatusBar() {
            getAttachedNode().setOnMouseEntered(observableValue -> {
                this.showStatusBarHint();
            });
    
            getAttachedNode().setOnMouseExited(observableValue -> {
                getTooltipHintController().setDefaultStatusBarText();
            });
        }
    }
    

    Теперь создадим по классу на оба случая. Вот класс для Region:

    
    import javafx.scene.control.Control;
    import javafx.scene.control.Tooltip;
    import javafx.scene.image.Image;
    import javafx.scene.image.ImageView;
    
    public final class TooltipHintRegionItem extends ATooltipHintItem<Region>{
        public TooltipHintRegionItem(Region attachedNode, ITooltipHintController tooltipHintController, String statusBarHint) {
            super(attachedNode, tooltipHintController, statusBarHint);
        }
    }
    

    А вот для Control:

    
    import javafx.scene.control.Control;
    import javafx.scene.control.Tooltip;
    import javafx.scene.image.Image;
    import javafx.scene.image.ImageView;
    
    public final class TooltipHintControlItem extends ATooltipHintItem<Control> {
        private Tooltip tooltip;
        public Tooltip getTooltip() {
            return tooltip;
        }
    
        private String tooltipHint;
        public TooltipHintControlItem setTooltipHint(String hint){
            tooltipHint = hint;
            if(hint == null || hint.trim().length() <= 0)
                return this;
            if(tooltip == null) {
                initTooltip();
            }
            tooltip.setText(hint);
            return this;
        }
        public String getTooltipHint(){
            return tooltipHint;
        }
    
        private Image tooltipImage;
        public TooltipHintControlItem setTooltipImage(Image image){
            tooltipImage = image;
            if(tooltip != null) tooltip.setGraphic((image != null) ? new ImageView(image) : null);
            return this;
        }
        public Image getTooltipImage(){
            return tooltipImage;
        }
    
        public TooltipHintControlItem(Control attachedNode, ITooltipHintController tooltipHintController, String statusBarHint, String tooltipHint, Image imageHint) {
            super(attachedNode, tooltipHintController, statusBarHint);
            if(tooltipHint != null && !tooltipHint.isEmpty()){
                initTooltip();
            }
            setTooltipHint(tooltipHint);
    
            if(imageHint == null) {
                setTooltipImage(imageHint);
            }
        }
    
        public TooltipHintControlItem(Control attachedNode, ITooltipHintController tooltipHintController, String statusBarHint, String tooltipHint) {
            this(attachedNode, tooltipHintController, statusBarHint, tooltipHint, null);
        }
    
        public TooltipHintControlItem(Control attachedNode, ITooltipHintController tooltipHintController, String statusBarHint) {
            this(attachedNode, tooltipHintController, statusBarHint, null, null);
        }
    
        private void initTooltip() {
            tooltip = new Tooltip();
            getAttachedNode().setTooltip(tooltip);
        }
    }
    

    Контролируем


    Теперь берёмся за Controller. А контроллер у нас начинается с интерфейса. Java без интерфейса — это как хакер без ноутбука.

    
    public interface ITooltipHintController {
        void setStatusBarText(String text);
        String getStatusBarText();
        void setDefaultStatusBarText();
    }
    

    А вот и сам контроллер:

    
    import javafx.collections.FXCollections;
    import javafx.collections.ObservableList;
    import javafx.scene.Node;
    import javafx.scene.control.Control;
    import javafx.scene.control.Labeled;
    import javafx.scene.image.Image;
    import javafx.scene.layout.Region;
    
    import java.util.ArrayList;
    import java.util.Iterator;
    
    public final class TooltipHintController implements ITooltipHintController {
        private final String DefaultStatusBarText = "";
        private final Labeled statusBarControl;
        private final ObservableList<ATooltipHintItem> tooltipHintItems;
    
        private boolean isStatusBarLocked = false;
        public boolean getIsStatusBarLocked() {
            return isStatusBarLocked;
        }
        public void setIsStatusBarLocked(boolean isStatusBarLocked) {
            this.isStatusBarLocked = isStatusBarLocked;
        }
    
        public Labeled getStatusBarControl() {
            return this.statusBarControl;
        }
    
        public void setStatusBarTextForce(String text) {
            if(statusBarControl == null) {
                return;
            }
            statusBarControl.setText(text);
        }
        @Override
        public void setStatusBarText(String text) {
            if(!isStatusBarLocked){
                setStatusBarTextForce(text);
            }
        }
        @Override
        public String getStatusBarText() {
            return (statusBarControl != null) ? statusBarControl.getText() : "";
        }
        @Override
        public void setDefaultStatusBarText(){
            setStatusBarTextForce(DefaultStatusBarText);
        }
    
        //тут есть дублирование кода, но пока ничего серьёзного
        public void addTooltipHint(Region region, String statusBarHint){
            // Tooltip нас не интересует - у регионов в JavaFX не бывает всплывающих подсказок
            ATooltipHintItem tooltipHintItem = findTooltipHint(region);
            if(tooltipHintItem == null) {
                tooltipHintItem = new TooltipHintRegionItem(region, this, statusBarHint);
                tooltipHintItems.add(tooltipHintItem);
            } else {
                TooltipHintControlItem tooltipHintControlItem = (TooltipHintControlItem)tooltipHintItem;
                if(statusBarHint != null && tooltipHintControlItem.getStatusBarHint() == null)
                    tooltipHintControlItem.setStatusBarHint(statusBarHint);
            }
        }
    
        public void addTooltipHint(Control control, String statusBarHint){
            addTooltipHint(control, statusBarHint, null, null);
        }
    
        public void addTooltipHint(Control control, String statusBarHint, String tooltipHint){
            addTooltipHint(control, statusBarHint, tooltipHint, null);
        }
    
        public void addTooltipHint(Control control, String statusBarHint, String tooltipHint, Image image){
            ATooltipHintItem tooltipHintItem = findTooltipHint(control);
            if(tooltipHintItem == null) {
                tooltipHintItem = new TooltipHintControlItem(control, this, statusBarHint, tooltipHint, image);
                tooltipHintItems.add(tooltipHintItem);
            } else {
                TooltipHintControlItem tooltipHintControlItem = (TooltipHintControlItem)tooltipHintItem;
                if(statusBarHint != null && tooltipHintControlItem.getStatusBarHint() == null)
                    tooltipHintControlItem.setStatusBarHint(statusBarHint);
    
                if(tooltipHint != null && tooltipHintControlItem.getTooltipHint() == null)
                    tooltipHintControlItem.setTooltipHint(tooltipHint);
    
                if(image != null && tooltipHintControlItem.getTooltipImage() == null)
                    tooltipHintControlItem.setTooltipImage(image);
            }
        }
    
        public void removeTooltipHint(Node control){
            ATooltipHintItem tooltipHintItem = null;
            Iterator<ATooltipHintItem> iteratorTooltipHintItems = tooltipHintItems.iterator();
            while(iteratorTooltipHintItems.hasNext()){
                tooltipHintItem = iteratorTooltipHintItems.next();
                if(tooltipHintItem.getAttachedNode() == control){
                    tooltipHintItems.remove(tooltipHintItem);
                    break;
                }
            }
        }
    
        public ATooltipHintItem findTooltipHint(Node control){
            for(ATooltipHintItem tooltipHintItem : tooltipHintItems)
                if(tooltipHintItem.getAttachedNode() == control)
                    return tooltipHintItem;
            return null;
        }
    
        /**
         * При создании нужно привязать контроллер к компоненту, который будет
         * показывать подсказки.
         *
         * @param statusBarControl Компонент для подсказок
         */
    
        public TooltipHintController(Labeled statusBarControl){
            this.statusBarControl = statusBarControl;
            tooltipHintItems = FXCollections.observableList(new ArrayList<>());
        }
    
        public TooltipHintController(){
            this(null);
        }
    
        private static TooltipHintController mainInstance;
        public static TooltipHintController getMainInstance() {
            if(mainInstance == null){
                mainInstance = new TooltipHintController();
            }
    
            return mainInstance;
        }
        public static void setMainInstance(TooltipHintController tooltipHintController) {
            mainInstance = tooltipHintController;
        }
    }
    

    Дальше можно смело писать, даже не задумываясь, кто от кого унаследован:

    TooltipHintController.getMainInstance().addTooltipHint(buttonStart, "Нажми меня", "Нажми эту кнопку");
    TooltipHintController.getMainInstance().addTooltipHint(paneButtons, "Здесь нажимают");

    А если у нас есть labelStatusBar в качестве строки состояния, то мы можем использовать и его:

    TooltipHintController.setMainInstance(new TooltipHintController(labelStatusBar));

    Конечно, эту реализацию стоит доработать — ведь чисто теоретически в приложении может быть и больше одной строки состояния. Если вам такие известны (разумеется, современные, на JavaFx и активно используемые) — дайте знать.

    Заключение


    К сожалению, этот набор из 4 классов слишком объёмен, чтобы свестись к одному документу на pastebin. Но и слишком мал и несамостоятелен, чтобы стать гордым maven-пакетом и занять почётное место в известном репозитории.

    Но я всё равно надеюсь, что он найдёт своего пользователя (точнее, своего разработчика). А может, он попадётся на глаза разработчикам из Oracle и убедит их сделать подсказки удобней.

    Есть одобренные самой Oracle библиотеки расширений — возможно. стоит попытаться пристроить этот интерфейс в одну из них?
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 12
    • +1
      Где и когда Вы предлагаете вызывать TooltipHintController#removeTooltipHint?
      • 0
        Над этим я не задумывался. Просто старый добрый принцип из Воннегутовых «Сирен Титана» — пользователь чувствует себя в безопасности, если на аппарате есть кнопка ВЫКЛ
        • +1
          Это очень важный момент — в противном случае Ваш TooltipHintController будет хранить референсы на все добавленные контролы/панели, что приведет к утечки памяти.
          Как вариант, добавить listener к свойству sceneProperty корневого элемента окна/виджета/области (Node#sceneProperty), когда оно будет null — весь граф контролов не отображается более на экране, то есть можно почистить все подсказки, биндинги, подписчики и т.д.
          • 0
            Думаю, это было бы разумно.

            Другой вопрос — важна ли эта реализация где-то за пределами моего приложения и куда её можно пристроить?
      • 0
        «Настолько простые и удобные подсказки были предметом величайшей зависти тех, кто сидел на MFC» — насколько помню, именно в MFC была впервые реализована концепция подсказок и статусных строк (точнее, концепция была в голом Win32-API, а в MFC удобная обёртка, бравшая на себя рутину по связи идентификаторов менюшек с идентификатором строк подсказок). Более того, строки подсказок лежали в «ресурсах» (.res), что позволяло их легко локализовывать на разные языки даже в готовом EXEшнике без исходников.
        • 0
          Видимо, это было уже в более-менее новых версиях. На моей памяти там был CToolTipCtrl c непредсказуемым синтаксисом на макросах.
          • 0
            Порог вхождения в VC++ + MFC для начинающих программистов был выше, чем в Delphi + VCL или, например, в VB, но при должном понимании никаких проблем не было с подсказками.
            • 0
              Да, всё верно. Завистники оставались за порогом вхождения
              • 0
                Значит, завистники не могли «сидеть» на MFC, если не преодолели порог вхождения. А если преодолели, то и подсказки, и остальные аспекты библиотеки были понятны и логичны, т.к. следовали единым принципам. Вы уж простите, что привязался, но не нужно свою некомпетенцию (в прошлом) так смело обобщать до свойств библиотеки или до трудностей всех программистов, использующих её. Работа с ресурсами в MFC была отличной.
                • 0
                  Всё в порядке. Я тогда в школе ещё учился, так что оставался в числе завистников.

                  Потом, конечно, были WinForms (куда привлекли бывшую команду из VCL, насколько я знаю). MVC стало меньше, зато порог вхождения снизился
        • +1
          Не подумайте, что я придираюсь, но строки на пустоту лучше проверять с помощью .isEmpty()
          • +1
            Это код вывешен именно для того, чтобы к нему придирались. Всё правильно вы говорите.

            Уже исправил.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое