Eric's Blog


- Game - Engine - Tool - Math -


How to Make Tools in UE4

This article is based on Unreal 4.17 code base, tested in Unreal 4.23.

This is a step by step tutorial to write tools for your Unreal project. I would assume you are familiar with Unreal already. This is NOT a tutorial for SLATE code, that deserves a tutorial for its own, and there are lots of SLATE example in Unreal already. With that said there will be some basic SLATE code in this tutorial to build UI widget, and I will try to show some different use cases for each example.

The example project is available in https://github.com/lxjk/ToolExample . Right click on the "ToolExample.uproject" and choose Switch Unreal Engine version to link to your engine.

Setup Editor Module

To make proper tools in Unreal it is almost a must to setup a custom editor module first. This will provide you an entry point for you custom tools, and also make sure your tool will not be included other than running in editor.

Here we create a new ToolExample project.

First we want to create a "ToolExampleEditor" folder and add the following files. This will be our new editor module.

001.png

IExampleModuleInterface.h

In this header, we first define IExampleModuleListenerInterface, a convenient interface to provide event when our module starts up or shuts down. Almost all our later tools will need to implement this interface.

Then we define IExampleModuleInterface, this is not necessary if you only have one editor module, but if you have more than that, this will handle event broadcasting for you. It is required that a module inherit from IModuleInterface, so our interface will inherit from the same class.

IExampleModuleInterface.h
#include "ModuleManager.h"

class IExampleModuleListenerInterface
{
public:
    virtual void OnStartupModule() {};
    virtual void OnShutdownModule() {};
};

class IExampleModuleInterface : public IModuleInterface
{
public:
    void StartupModule() override
    {
        if (!IsRunningCommandlet())
        {
            AddModuleListeners();
            for (int32 i = 0; i < ModuleListeners.Num(); ++i)
            {
                ModuleListeners[i]->OnStartupModule();
            }
        }
    }

    void ShutdownModule() override
    {
        for (int32 i = 0; i < ModuleListeners.Num(); ++i)
        {
            ModuleListeners[i]->OnShutdownModule();
        }
    }

    virtual void AddModuleListeners() {};

protected:
    TArray<TSharedRef<IExampleModuleListenerInterface>> ModuleListeners;
};

ToolExmampleEditor.Build.cs

This file you can copy from ToolExample.Build.cs. We added commonly used module names to dependency. Note we add "ToolExample" module here as well.

ToolExmampleEditor.Build.cs
PublicDependencyModuleNames.AddRange(
            new string[] {
                "Core",
                "Engine",
                "CoreUObject",
                "InputCore",
                "LevelEditor",
                "Slate",
                "EditorStyle",
                "AssetTools",
                "EditorWidgets",
                "UnrealEd",
                "BlueprintGraph",
                "AnimGraph",
                "ComponentVisualizers",
                "ToolExample"
        }
        );


PrivateDependencyModuleNames.AddRange(
            new string[]
            {
                "Core",
                "CoreUObject",
                "Engine",
                "AppFramework",
                "SlateCore",
                "AnimGraph",
                "UnrealEd",
                "KismetWidgets",
                "MainFrame",
                "PropertyEditor",
                "ComponentVisualizers",
                "ToolExample"
            }
            );

ToolExampleEditor.h & ToolExampleEditor.cpp

Here we define the actual module class, implementing IExampleModuleInterface we defined above. We include headers we need for following sections as well. Make sure the module name you use to get module is the same as the one you pass in IMPLEMENT_GAME_MODULE macro.

ToolExampleEditor.h
#include "UnrealEd.h"
#include "SlateBasics.h"
#include "SlateExtras.h"
#include "Editor/LevelEditor/Public/LevelEditor.h"
#include "Editor/PropertyEditor/Public/PropertyEditing.h"
#include "IAssetTypeActions.h"
#include "IExampleModuleInterface.h"

class FToolExampleEditor : public IExampleModuleInterface
{
public:
    /** IModuleInterface implementation */
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;

    virtual void AddModuleListeners() override;

    static inline FToolExampleEditor& Get()
    {
        return FModuleManager::LoadModuleChecked< FToolExampleEditor >("ToolExampleEditor");
    }

    static inline bool IsAvailable()
    {
        return FModuleManager::Get().IsModuleLoaded("ToolExampleEditor");
    }
};
ToolExampleEditor.cpp
#include "ToolExampleEditor.h"
#include "IExampleModuleInterface.h"

IMPLEMENT_GAME_MODULE(FToolExampleEditor, ToolExampleEditor)

void FToolExampleEditor::AddModuleListeners()
{
    // add tools later
}

void FToolExampleEditor::StartupModule()
{
    IExampleModuleInterface::StartupModule();
}

void FToolExampleEditor::ShutdownModule()
{
    IExampleModuleInterface::ShutdownModule();
}

ToolExampleEditor.Target.cs

We need to modify this file to load our module in Editor mode (Don’t change ToolExample.Target.cs), add the following:

ToolExampleEditor.Target.cs
ExtraModuleNames.AddRange( new string[] { "ToolExampleEditor" });

ToolExample.uproject

Similarly, we need to include our modules here, add the following:

ToolExample.uproject
{
    "Name": "ToolExampleEditor",
    "Type": "Editor",
    "LoadingPhase": "PostEngineInit",
    "AdditionalDependencies": [
        "Engine"
    ]
}

Now the editor module should be setup properly.

Add Custom Menu

Next we are going to add a custom menu, so we can add widget in the menu to run a command or open up a window.

First we need to add menu extensions related functions in our editor module ToolExampleEditor:

ToolExampleEditor.h
public:
    void AddMenuExtension(const FMenuExtensionDelegate &extensionDelegate, FName extensionHook, const TSharedPtr<FUICommandList> &CommandList = NULL, EExtensionHook::Position position = EExtensionHook::Before);
    TSharedRef<FWorkspaceItem> GetMenuRoot() { return MenuRoot; };

protected:
    TSharedPtr<FExtensibilityManager> LevelEditorMenuExtensibilityManager;
    TSharedPtr<FExtender> MenuExtender;

    static TSharedRef<FWorkspaceItem> MenuRoot;

    void MakePulldownMenu(FMenuBarBuilder &menuBuilder);
    void FillPulldownMenu(FMenuBuilder &menuBuilder);

In the cpp file, define MenuRoot and add the implement all the functions. Here we will add a menu called "Example" and create 2 sections: "Section 1" and "Section 2", with extension hook name "Section_1" and "Section_2".

ToolExampleEditor.cpp
TSharedRef<FWorkspaceItem> FToolExampleEditor::MenuRoot = FWorkspaceItem::NewGroup(FText::FromString("Menu Root"));


void FToolExampleEditor::AddMenuExtension(const FMenuExtensionDelegate &extensionDelegate, FName extensionHook, const TSharedPtr<FUICommandList> &CommandList, EExtensionHook::Position position)
{
    MenuExtender->AddMenuExtension(extensionHook, position, CommandList, extensionDelegate);
}

void FToolExampleEditor::MakePulldownMenu(FMenuBarBuilder &menuBuilder)
{
    menuBuilder.AddPullDownMenu(
        FText::FromString("Example"),
        FText::FromString("Open the Example menu"),
        FNewMenuDelegate::CreateRaw(this, &FToolExampleEditor::FillPulldownMenu),
        "Example",
        FName(TEXT("ExampleMenu"))
    );
}

void FToolExampleEditor::FillPulldownMenu(FMenuBuilder &menuBuilder)
{
    // just a frame for tools to fill in
    menuBuilder.BeginSection("ExampleSection", FText::FromString("Section 1"));
    menuBuilder.AddMenuSeparator(FName("Section_1"));
    menuBuilder.EndSection();

    menuBuilder.BeginSection("ExampleSection", FText::FromString("Section 2"));
    menuBuilder.AddMenuSeparator(FName("Section_2"));
    menuBuilder.EndSection();
}

Finally in StartupModule we add the following before we call the parent function. We add our menu after "Window" menu.

ToolExampleEditor.cpp
void FToolExampleEditor::StartupModule()
{
    if (!IsRunningCommandlet())
    {
        FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor");
        LevelEditorMenuExtensibilityManager = LevelEditorModule.GetMenuExtensibilityManager();
        MenuExtender = MakeShareable(new FExtender);
        MenuExtender->AddMenuBarExtension("Window", EExtensionHook::After, NULL, FMenuBarExtensionDelegate::CreateRaw(this, &FToolExampleEditor::MakePulldownMenu));
        LevelEditorMenuExtensibilityManager->AddExtender(MenuExtender);
    }
    IExampleModuleInterface::StartupModule();
}

Now if you run it you should see the custom menu get added with two sections.

002.png

Next we can add our first tool to register to our menu. First add two new files:

003.png

This class will inherit from IExampleModuleListenerInterface, and we add function to create menu entry. We also add FUICommandList, which will define and map a menu item to a function. Finally we add our only menu function MenuCommand1, this function will be called when user click on the menu item.

MenuTool.h
#include "ToolExampleEditor/IExampleModuleInterface.h"

class MenuTool : public IExampleModuleListenerInterface, public TSharedFromThis<MenuTool>
{
public:
    virtual ~MenuTool() {}

    virtual void OnStartupModule() override;
    virtual void OnShutdownModule() override;

    void MakeMenuEntry(FMenuBuilder &menuBuilder);

protected:
    TSharedPtr<FUICommandList> CommandList;

    void MapCommands();

    // UI Command functions
    void MenuCommand1();
};

On the cpp side, we got a lot more to do. First we need to define LOCTEXT_NAMESPACE at the beginning, and un-define it at the end. This is required to use UI_COMMAND macro. Then we start filling in each command, first create a FUICommandInfo member for each command in command list class, fill in RegisterCommands function by using UI_COMMAND marcro. Then in MapCommands function map each command info to a function. And of course define the command function MenuTool::MenuCommand1.

In OnStartupModule, we create command list, register it, map it, then register to menu extension. In this case we want our item in "Section 1", and MakeMenuEntry will be called when Unreal build the menu, in which we simply add MenuCommand1 to the menu.

In OnShutdownModule, we need to unregister command list.

MenuTool.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "MenuTool.h"

#define LOCTEXT_NAMESPACE "MenuTool"

class MenuToolCommands : public TCommands<MenuToolCommands>
{
public:

    MenuToolCommands()
        : TCommands<MenuToolCommands>(
        TEXT("MenuTool"), // Context name for fast lookup
        FText::FromString("Example Menu tool"), // Context name for displaying
        NAME_None,   // No parent context
        FEditorStyle::GetStyleSetName() // Icon Style Set
        )
    {
    }

    virtual void RegisterCommands() override
    {
        UI_COMMAND(MenuCommand1, "Menu Command 1", "Test Menu Command 1.", EUserInterfaceActionType::Button, FInputGesture());

    }

public:
    TSharedPtr<FUICommandInfo> MenuCommand1;
};

void MenuTool::MapCommands()
{
    const auto& Commands = MenuToolCommands::Get();

    CommandList->MapAction(
        Commands.MenuCommand1,
        FExecuteAction::CreateSP(this, &MenuTool::MenuCommand1),
        FCanExecuteAction());
}

void MenuTool::OnStartupModule()
{
    CommandList = MakeShareable(new FUICommandList);
    MenuToolCommands::Register();
    MapAction();
    FToolExampleEditor::Get().AddMenuExtension(
        FMenuExtensionDelegate::CreateRaw(this, &MenuTool::MakeMenuEntry),
        FName("Section_1"),
        CommandList);
}

void MenuTool::OnShutdownModule()
{
    MenuToolCommands::Unregister();
}

void MenuTool::MakeMenuEntry(FMenuBuilder &menuBuilder)
{
    menuBuilder.AddMenuEntry(MenuToolCommands::Get().MenuCommand1);
}

void MenuTool::MenuCommand1()
{
    UE_LOG(LogClass, Log, TEXT("clicked MenuCommand1"));
}

#undef LOCTEXT_NAMESPACE

When this is all done, remember to add this tool as a listener to editor module in FToolExampleEditor::AddModuleListeners:

ToolExampleEditor.cpp
ModuleListeners.Add(MakeShareable(new MenuTool));

Now if you build the project, you should see your menu item in the menu. And if you click on it, it will print "clicked MenuCommand1".

By now you have a basic framework for tools, You can run anything you want based on a menu click.

004.png

Advanced Menu

Before we jump to window, let’s extend menu functionality for a bit, since there are a lot more you can do.

First if you have a lot of items, it will be good to put them in a sub menu. Let’s make two more commands MenuCommand2 and MenuCommand3. You can search for MenuCommand1 and create two more in each places, other than MakeMenuEntry, where we will add sub menu.

In MenuTool, we add function for sub menu:

MenuTool.h
void MakeSubMenu(FMenuBuilder &menuBuilder);
MenuTool.cpp
void MenuTool::MakeSubMenu(FMenuBuilder &menuBuilder)
{
    menuBuilder.AddMenuEntry(MenuToolCommands::Get().MenuCommand2);
    menuBuilder.AddMenuEntry(MenuToolCommands::Get().MenuCommand3);
}

Then we call AddSubMenu in MenuTool::MakeMenuEntry, after MenuCommand1 is registered so the submenu comes after that.

MenuTool.cpp
void MenuTool::MakeMenuEntry(FMenuBuilder &menuBuilder)
{
    ...
    menuBuilder.AddSubMenu(
        FText::FromString("Sub Menu"),
        FText::FromString("This is example sub menu"),
        FNewMenuDelegate::CreateSP(this, &MenuTool::MakeSubMenu)
    );
}

Now you should see sub menu like the following:

005.png

Not only you can add simple menu item, you can actually add any widget into the menu. We will try to make a small tool that you can type in a textbox and click a button to set that as tags for selected actors.

I’m not going to go into details for each functions I used here, search them in Unreal engine and you should find plenty of use cases.

First we add needed member and functions, note this time we are going to use custom widget, so we don’t need to change command list. For AddTag fucntion, because it is going to be used for a button, return type have to be FReply.

MenuTool.h
FName TagToAdd;

FReply AddTag();
FText GetTagToAddText() const;
void OnTagToAddTextCommited(const FText& InText, ETextCommit::Type CommitInfo);

Then we fill in those functions. If you type in a text, we save it to TagToAdd. If you click on the button, we search all selected actors and make the tag change. We wrap it around a transaction so it will support undo. To use transaction we need to include "ScopedTransaction.h".

MenuTool.cpp
FReply MenuTool::AddTag()
{
    if (!TagToAdd.IsNone())
    {
        const FScopedTransaction Transaction(FText::FromString("Add Tag"));
        for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It)
        {
            AActor* Actor = static_cast<AActor*>(*It);
            if (!Actor->Tags.Contains(TagToAdd))
            {
                Actor->Modify();
                Actor->Tags.Add(TagToAdd);
            }
        }
    }
    return FReply::Handled();
}

FText MenuTool::GetTagToAddText() const
{
    return FText::FromName(TagToAdd);
}

void MenuTool::OnTagToAddTextCommited(const FText& InText, ETextCommit::Type CommitInfo)
{
    FString str = InText.ToString();
    TagToAdd = FName(*str.Trim());
}

Then in MenuTool::MakeMenuEntry, we create the widget and add it to the menu. Again I will not go into Slate code details.

MenuTool.cpp
void MenuTool::MakeMenuEntry(FMenuBuilder &menuBuilder)
{
    ...
    TSharedRef<SWidget> AddTagWidget =
        SNew(SHorizontalBox)
        + SHorizontalBox::Slot()
        .AutoWidth()
        .VAlign(VAlign_Center)
        [
            SNew(SEditableTextBox)
            .MinDesiredWidth(50)
            .Text(this, &MenuTool::GetTagToAddText)
            .OnTextCommitted(this, &MenuTool::OnTagToAddTextCommited)
        ]
        + SHorizontalBox::Slot()
        .AutoWidth()
        .Padding(5, 0, 0, 0)
        .VAlign(VAlign_Center)
        [
            SNew(SButton)
            .Text(FText::FromString("Add Tag"))
            .OnClicked(this, &MenuTool::AddTag)
        ];

    menuBuilder.AddWidget(AddTagWidget, FText::FromString(""));
}

Now you have a more complex tool sit in the menu, and you can set actor tags with it:

006.png

Create a Tab (Window)

While we can do a lot in the menu, it is still more convenient and flexible if you have a window. In Unreal it is called "tab". Because creating a tab from menu is very common for tools, we will make a base class for it first.

Add a new file:

007.png

The base class is also inherit from IExampleModuleListenerInterface. In OnStartupModule we register a tab, and unregister it in OnShutdownModule. Then in MakeMenuEntry, we let FGlobalTabmanager to populate tab for this menu item. We leave SpawnTab function to be overriden by child class to set proper widget.

ExampleTabToolBase.h
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ToolExampleEditor/IExampleModuleInterface.h"
#include "TabManager.h"
#include "SDockTab.h"

class FExampleTabToolBase : public IExampleModuleListenerInterface, public TSharedFromThis< FExampleTabToolBase >
{
public:
    // IPixelopusToolBase
    virtual void OnStartupModule() override
    {
        Initialize();
        FGlobalTabmanager::Get()->RegisterNomadTabSpawner(TabName, FOnSpawnTab::CreateRaw(this, &FExampleTabToolBase::SpawnTab))
            .SetGroup(FToolExampleEditor::Get().GetMenuRoot())
            .SetDisplayName(TabDisplayName)
            .SetTooltipText(ToolTipText);
    };

    virtual void OnShutdownModule() override
    {
        FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(TabName);
    };

    // In this function set TabName/TabDisplayName/ToolTipText
    virtual void Initialize() {};
    virtual TSharedRef<SDockTab> SpawnTab(const FSpawnTabArgs& TabSpawnArgs) { return SNew(SDockTab); };

    virtual void MakeMenuEntry(FMenuBuilder &menuBuilder)
    {
        FGlobalTabmanager::Get()->PopulateTabSpawnerMenu(menuBuilder, TabName);
    };

protected:
    FName TabName;
    FText TabDisplayName;
    FText ToolTipText;
};

Now we add files for tab tool. Other than the normal tool class, we also need a custom panel widget class for the tab itself.

008.png

Let’s look at TabTool class first, it is inherited from ExampleTabToolBase defined above.

We set tab name, display name and tool tips in Initialize function, and prepare the panel in SpawnTab function. Note here we send the tool object itself as a parameter when creating the panel. This is not necessary, but as an example how you can pass in an object to the widget.

This tab tool is added in "Section 2" in the custom menu.

TabTool.h
#include "ToolExampleEditor/ExampleTabToolBase.h"

class TabTool : public FExampleTabToolBase
{
public:
    virtual ~TabTool () {}
    virtual void OnStartupModule() override;
    virtual void OnShutdownModule() override;
    virtual void Initialize() override;
    virtual TSharedRef<SDockTab> SpawnTab(const FSpawnTabArgs& TabSpawnArgs) override;
};
TabTool.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "TabToolPanel.h"
#include "TabTool.h"

void TabTool::OnStartupModule()
{
    FExampleTabToolBase::OnStartupModule();
    FToolExampleEditor::Get().AddMenuExtension(FMenuExtensionDelegate::CreateRaw(this, &TabTool::MakeMenuEntry), FName("Section_2"));
}

void TabTool::OnShutdownModule()
{
    FExampleTabToolBase::OnShutdownModule();
}

void TabTool::Initialize()
{
    TabName = "TabTool";
    TabDisplayName = FText::FromString("Tab Tool");
    ToolTipText = FText::FromString("Tab Tool Window");
}

TSharedRef<SDockTab> TabTool::SpawnTab(const FSpawnTabArgs& TabSpawnArgs)
{
    TSharedRef<SDockTab> SpawnedTab = SNew(SDockTab)
        .TabRole(ETabRole::NomadTab)
        [
            SNew(TabToolPanel)
            .Tool(SharedThis(this))
        ];

    return SpawnedTab;
}

Now for the pannel:

In the construct function we build the slate widget in ChildSlot. Here I’m add a scroll box, with a grey border inside, with a text box inside.

TabToolPanel.h
#include "SDockTab.h"
#include "SDockableTab.h"
#include "SDockTabStack.h"
#include "SlateApplication.h"
#include "TabTool.h"

class TabToolPanel : public SCompoundWidget
{
    SLATE_BEGIN_ARGS(TabToolPanel)
    {}
    SLATE_ARGUMENT(TWeakPtr<class TabTool>, Tool)
    SLATE_END_ARGS()

    void Construct(const FArguments& InArgs);

protected:
    TWeakPtr<TabTool> tool;
};
TabToolPanel.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "TabToolPanel.h"

void TabToolPanel::Construct(const FArguments& InArgs)
{
    tool = InArgs._Tool;
    if (tool.IsValid())
    {
        // do anything you need from tool object
    }

    ChildSlot
    [
        SNew(SScrollBox)
        + SScrollBox::Slot()
        .VAlign(VAlign_Top)
        .Padding(5)
        [
            SNew(SBorder)
            .BorderBackgroundColor(FColor(192, 192, 192, 255))
            .Padding(15.0f)
            [
                SNew(STextBlock)
                .Text(FText::FromString(TEXT("This is a tab example.")))
            ]
        ]
    ];
}

Finally remember to add this tool to editor module in FToolExampleEditor::AddModuleListeners:

ToolExampleEditor.cpp
ModuleListeners.Add(MakeShareable(new TabTool));

Now you can see tab tool in our custom menu:

009.png

When you click on it, it will populate a window you can dock anywhere as regular Unreal tab.

010.png

Customize Details Panel

Another commonly used feature is to customize the details panel for any UObject.

To show how it works, we will create an Actor class first in our game module "ToolExample". Add the follow file:

011.png

In this class, we add 2 booleans in "Options" category, and an integer in "Test" category. Remember to add "TOOLEXAMPLE_API" in front of class name to export it from game module, otherwise we cannot use it in editor module.

ExampleActor.h
#pragma once
#include "ExampleActor.generated.h"

UCLASS()
class TOOLEXAMPLE_API AExampleActor : public AActor
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category = "Options")
    bool bOption1 = false;

    UPROPERTY(EditAnywhere, Category = "Options")
    bool bOption2 = false;

    UPROPERTY(EditAnywhere, Category = "Test")
    int testInt = 0;
};

Now if we load up Unreal and drag a "ExampleActor", you should see the following in the details panel:

012.png

If we want option 1 and option 2 to be mutually exclusive. You can have both unchecked or one of them checked, but you cannot have both checked. We want to customize this details panel, so if user check one of them, it will automatically uncheck the other.

Add the following files to editor module "ToolExampleEditor":

013.png

The details customization implements IDetailCustomization interface. In the main entry point CustomizeDetails function, we first hide original properties option 1 and option 2 (you can comment out those two lines and see how it works). Then we add our custom widget, here the "RadioButton" is purely a visual style, it has nothing to do with mutually exclusive logic. You can implement the same logic with other visuals like regular check box, buttons, etc.

In the widget functions for check box, IsModeRadioChecked and OnModeRadioChanged we add extra parameters "actor" and "optionIndex", so we can pass in the editing object and specify option when we construct the widget.

ExampleActorDetails.h
#pragma once
#include "IDetailCustomization.h"

class AExampleActor;

class FExampleActorDetails : public IDetailCustomization
{
public:
    /** Makes a new instance of this detail layout class for a specific detail view requesting it */
    static TSharedRef<IDetailCustomization> MakeInstance();

    /** IDetailCustomization interface */
    virtual void CustomizeDetails(IDetailLayoutBuilder& DetailLayout) override;

protected:
    // widget functions
    ECheckBoxState IsModeRadioChecked(AExampleActor* actor, int optionIndex) const;
    void OnModeRadioChanged(ECheckBoxState CheckType, AExampleActor* actor, int optionIndex);
};
ExampleActorDetails.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ExampleActorDetails.h"
#include "DetailsCustomization/ExampleActor.h"

TSharedRef<IDetailCustomization> FExampleActorDetails::MakeInstance()
{
    return MakeShareable(new FExampleActorDetails);
}

void FExampleActorDetails::CustomizeDetails(IDetailLayoutBuilder& DetailLayout)
{
    TArray<TWeakObjectPtr<UObject>> Objects;
    DetailLayout.GetObjectsBeingCustomized(Objects);
    if (Objects.Num() != 1)
    {
        // skip customization if select more than one objects
        return;
    }
    AExampleActor* actor = (AExampleActor*)Objects[0].Get();

    // hide original property
    DetailLayout.HideProperty(DetailLayout.GetProperty(GET_MEMBER_NAME_CHECKED(AExampleActor, bOption1)));
    DetailLayout.HideProperty(DetailLayout.GetProperty(GET_MEMBER_NAME_CHECKED(AExampleActor, bOption2)));

    // add custom widget to "Options" category
    IDetailCategoryBuilder& OptionsCategory = DetailLayout.EditCategory("Options", FText::FromString(""), ECategoryPriority::Important);
    OptionsCategory.AddCustomRow(FText::FromString("Options"))
                .WholeRowContent()
                [
                    SNew(SHorizontalBox)
                    + SHorizontalBox::Slot()
                    .AutoWidth()
                    .VAlign(VAlign_Center)
                    [
                        SNew(SCheckBox)
                        .Style(FEditorStyle::Get(), "RadioButton")
                        .IsChecked(this, &FExampleActorDetails::IsModeRadioChecked, actor, 1)
                        .OnCheckStateChanged(this, &FExampleActorDetails::OnModeRadioChanged, actor, 1)
                        [
                            SNew(STextBlock).Text(FText::FromString("Option 1"))
                        ]
                    ]
                    + SHorizontalBox::Slot()
                    .AutoWidth()
                    .Padding(10.f, 0.f, 0.f, 0.f)
                    .VAlign(VAlign_Center)
                    [
                        SNew(SCheckBox)
                        .Style(FEditorStyle::Get(), "RadioButton")
                        .IsChecked(this, &FExampleActorDetails::IsModeRadioChecked, actor, 2)
                        .OnCheckStateChanged(this, &FExampleActorDetails::OnModeRadioChanged, actor, 2)
                        [
                            SNew(STextBlock).Text(FText::FromString("Option 2"))
                        ]
                    ]
                ];
}

ECheckBoxState FExampleActorDetails::IsModeRadioChecked(AExampleActor* actor, int optionIndex) const
{
    bool bFlag = false;
    if (actor)
    {
        if (optionIndex == 1)
            bFlag = actor->bOption1;
        else if (optionIndex == 2)
            bFlag = actor->bOption2;
    }
    return bFlag ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}

void FExampleActorDetails::OnModeRadioChanged(ECheckBoxState CheckType, AExampleActor* actor, int optionIndex)
{
    bool bFlag = (CheckType == ECheckBoxState::Checked);
    if (actor)
    {
        actor->Modify();
        if (bFlag)
        {
            // clear all options first
            actor->bOption1 = false;
            actor->bOption2 = false;
        }
        if (optionIndex == 1)
            actor->bOption1 = bFlag;
        else if (optionIndex == 2)
            actor->bOption2 = bFlag;
    }
}

Then we need to register the layout in FToolExampleEditor::StartupModule and unregister it in FToolExampleEditor::ShutdownModule

ToolExampleEditor.cpp
#include "DetailsCustomization/ExampleActor.h"
#include "DetailsCustomization/ExampleActorDetails.h"

void FToolExampleEditor::StartupModule()
{
    ...

    // register custom layouts
    {
        static FName PropertyEditor("PropertyEditor");
        FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>(PropertyEditor);
        PropertyModule.RegisterCustomClassLayout(AExampleActor::StaticClass()->GetFName(), FOnGetDetailCustomizationInstance::CreateStatic(&FExampleActorDetails::MakeInstance));
    }

    IExampleModuleInterface::StartupModule();
}

void FToolExampleEditor::ShutdownModule()
{
    // unregister custom layouts
    if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
    {
        FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
        PropertyModule.UnregisterCustomClassLayout(AExampleActor::StaticClass()->GetFName());
    }

    IExampleModuleInterface::ShutdownModule();
}

Now you should see the customized details panel:

014.png

Custom Data Type

New Custom Data

For simple data, you can just inherit from UDataAsset class, then you can create your data object in Unreal content browser: Add New → miscellaneous → Data Asset

If you want to add you data to a custom category, you need to do a bit more work.

First we need to create a custom data type in game module (ExampleTool). We will make one with only one property.

015.png

We add "SourceFilePath" for future sections.

ExampleData.h
#pragma once
#include "ExampleData.generated.h"

UCLASS(Blueprintable)
class UExampleData : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, Category = "Properties")
    FString ExampleString;

#if WITH_EDITORONLY_DATA
    UPROPERTY(Category = SourceAsset, VisibleAnywhere)
    FString SourceFilePath;
#endif
};

Then in editor module, add the following files:

016.png

We first make the factory:

ExampleDataFactory.h
#pragma once
#include "UnrealEd.h"
#include "ExampleDataFactory.generated.h"

UCLASS()
class UExampleDataFactory : public UFactory
{
    GENERATED_UCLASS_BODY()
public:
    virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
};
ExampleDataFactory.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ExampleDataFactory.h"
#include "CustomDataType/ExampleData.h"

UExampleDataFactory::UExampleDataFactory(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
    SupportedClass = UExampleData::StaticClass();
    bCreateNew = true;
    bEditAfterNew = true;
}

UObject* UExampleDataFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
{
    UExampleData* NewObjectAsset = NewObject<UExampleData>(InParent, Class, Name, Flags | RF_Transactional);
    return NewObjectAsset;
}

Then we make type actions, here we will pass in the asset category.

ExampleDataTypeActions.h
#pragma once
#include "AssetTypeActions_Base.h"

class FExampleDataTypeActions : public FAssetTypeActions_Base
{
public:
    FExampleDataTypeActions(EAssetTypeCategories::Type InAssetCategory);

    // IAssetTypeActions interface
    virtual FText GetName() const override;
    virtual FColor GetTypeColor() const override;
    virtual UClass* GetSupportedClass() const override;
    virtual uint32 GetCategories() override;
    // End of IAssetTypeActions interface

private:
    EAssetTypeCategories::Type MyAssetCategory;
};
ExampleDataTypeActions.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ExampleDataTypeActions.h"
#include "CustomDataType/ExampleData.h"

FExampleDataTypeActions::FExampleDataTypeActions(EAssetTypeCategories::Type InAssetCategory)
    : MyAssetCategory(InAssetCategory)
{
}

FText FExampleDataTypeActions::GetName() const
{
    return FText::FromString("Example Data");
}

FColor FExampleDataTypeActions::GetTypeColor() const
{
    return FColor(230, 205, 165);
}

UClass* FExampleDataTypeActions::GetSupportedClass() const
{
    return UExampleData::StaticClass();
}

uint32 FExampleDataTypeActions::GetCategories()
{
    return MyAssetCategory;
}

Finally we need to register type actions in editor module. We add an array CreatedAssetTypeActions to save all type actions we registered, so we can unregister them properly when module is unloaded:

ToolExampleEditor.h
class FToolExampleEditor : public IExampleModuleInterface
{
    ...
    TArray<TSharedPtr<IAssetTypeActions>> CreatedAssetTypeActions;
}

In StartupModule function, we create a new "Example" category, and use that to register our type action.

ToolExampleEditor.cpp
#include "CustomDataType/ExampleDataTypeActions.h"

void FToolExampleEditor::StartupModule()
{
    ...

    // register custom types:
    {
        IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
        // add custom category
        EAssetTypeCategories::Type ExampleCategory = AssetTools.RegisterAdvancedAssetCategory(FName(TEXT("Example")), FText::FromString("Example"));
        // register our custom asset with example category
        TSharedPtr<IAssetTypeActions> Action = MakeShareable(new FExampleDataTypeActions(ExampleCategory));
        AssetTools.RegisterAssetTypeActions(Action.ToSharedRef());
        // saved it here for unregister later
        CreatedAssetTypeActions.Add(Action);
    }

    IExampleModuleInterface::StartupModule();
}

void FToolExampleEditor::ShutdownModule()
{
    ...

    // Unregister all the asset types that we registered
    if (FModuleManager::Get().IsModuleLoaded("AssetTools"))
    {
        IAssetTools& AssetTools = FModuleManager::GetModuleChecked<FAssetToolsModule>("AssetTools").Get();
        for (int32 i = 0; i < CreatedAssetTypeActions.Num(); ++i)
        {
            AssetTools.UnregisterAssetTypeActions(CreatedAssetTypeActions[i].ToSharedRef());
        }
    }
    CreatedAssetTypeActions.Empty();

    IExampleModuleInterface::ShutdownModule();
}

Now you will see your data in proper category.

017.png

Import Custom Data

For all the hard work we did above, we can now our data from a file, like the way you can drag and drop an PNG file to create a texture. In this case we will have a text file, with extension ".xmp", to be imported into unreal, and we just set the text from the file to "ExampleString" property.

To make it work with import, we actually have to disable the ability to be able to create a new data from scratch. Modify factory class as following:

ExampleDataFactory.h
class UExampleDataFactory : public UFactory
{
    ...

    virtual UObject* FactoryCreateText(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, const TCHAR* Type, const TCHAR*& Buffer, const TCHAR* BufferEnd, FFeedbackContext* Warn) override;
    virtual bool FactoryCanImport(const FString& Filename) override;

    // helper function
    static void MakeExampleDataFromText(class UExampleData* Data, const TCHAR*& Buffer, const TCHAR* BufferEnd);
};
ExampleDataFactory.cpp
UExampleDataFactory::UExampleDataFactory(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
    Formats.Add(TEXT("xmp;Example Data"));
    SupportedClass = UExampleData::StaticClass();
    bCreateNew = false; // turned off for import
    bEditAfterNew = false; // turned off for import
    bEditorImport = true;
    bText = true;
}


UObject* UExampleDataFactory::FactoryCreateText(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, const TCHAR* Type, const TCHAR*& Buffer, const TCHAR* BufferEnd, FFeedbackContext* Warn)
{
    FEditorDelegates::OnAssetPreImport.Broadcast(this, InClass, InParent, InName, Type);

    // if class type or extension doesn't match, return
    if (InClass != UExampleData::StaticClass() ||
        FCString::Stricmp(Type, TEXT("xmp")) != 0)
        return nullptr;

    UExampleData* Data = CastChecked<UExampleData>(NewObject<UExampleData>(InParent, InName, Flags));
    MakeExampleDataFromText(Data, Buffer, BufferEnd);

    // save the source file path
    Data->SourceFilePath = UAssetImportData::SanitizeImportFilename(CurrentFilename, Data->GetOutermost());

    FEditorDelegates::OnAssetPostImport.Broadcast(this, Data);

    return Data;
}

bool UExampleDataFactory::FactoryCanImport(const FString& Filename)
{
    return FPaths::GetExtension(Filename).Equals(TEXT("xmp"));
}

void UExampleDataFactory::MakeExampleDataFromText(class UExampleData* Data, const TCHAR*& Buffer, const TCHAR* BufferEnd)
{
    Data->ExampleString = Buffer;
}

Note we changed bCreateNew and bEditAfterNew to false. We set "SourceFilePath" so we can do reimport later. If you want to import binary file, set bText = false, and override FactoryCreateBinary function instead.

Now you can drag & drop a xmp file and have the content imported automatically.

018.png

If you want to have custom editor for the data, you can follow "Customize Details Panel" section to create custom widget. Or you can override OpenAssetEditor function in ExampleDataTypeActions, to create a complete different editor. We are not going to dive in here, search "OpenAssetEditor" in Unreal engine for examples.

Reimport

To reimport a file, we need to implement a different factory class. The implementation should be straight forward.

019.png
ReimportExampleDataFactory.h
#pragma once
#include "ExampleDataFactory.h"
#include "ReimportExampleDataFactory.generated.h"

UCLASS()
class UReimportExampleDataFactory : public UExampleDataFactory, public FReimportHandler
{
    GENERATED_BODY()

    // Begin FReimportHandler interface
    virtual bool CanReimport(UObject* Obj, TArray<FString>& OutFilenames) override;
    virtual void SetReimportPaths(UObject* Obj, const TArray<FString>& NewReimportPaths) override;
    virtual EReimportResult::Type Reimport(UObject* Obj) override;
    // End FReimportHandler interface
};
ReimportExampleDataFactory.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ReimportExampleDataFactory.h"
#include "ExampleDataFactory.h"
#include "CustomDataType/ExampleData.h"

bool UReimportExampleDataFactory::CanReimport(UObject* Obj, TArray<FString>& OutFilenames)
{
    UExampleData* ExampleData = Cast<UExampleData>(Obj);
    if (ExampleData)
    {
        OutFilenames.Add(UAssetImportData::ResolveImportFilename(ExampleData->SourceFilePath, ExampleData->GetOutermost()));
        return true;
    }
    return false;
}

void UReimportExampleDataFactory::SetReimportPaths(UObject* Obj, const TArray<FString>& NewReimportPaths)
{
    UExampleData* ExampleData = Cast<UExampleData>(Obj);
    if (ExampleData && ensure(NewReimportPaths.Num() == 1))
    {
        ExampleData->SourceFilePath = UAssetImportData::SanitizeImportFilename(NewReimportPaths[0], ExampleData->GetOutermost());
    }
}

EReimportResult::Type UReimportExampleDataFactory::Reimport(UObject* Obj)
{
    UExampleData* ExampleData = Cast<UExampleData>(Obj);
    if (!ExampleData)
    {
        return EReimportResult::Failed;
    }

    const FString Filename = UAssetImportData::ResolveImportFilename(ExampleData->SourceFilePath, ExampleData->GetOutermost());
    if (!FPaths::GetExtension(Filename).Equals(TEXT("xmp")))
    {
        return EReimportResult::Failed;
    }

    CurrentFilename = Filename;
    FString Data;
    if (FFileHelper::LoadFileToString(Data, *CurrentFilename))
    {
        const TCHAR* Ptr = *Data;
        ExampleData->Modify();
        ExampleData->MarkPackageDirty();

        UExampleDataFactory::MakeExampleDataFromText(ExampleData, Ptr, Ptr + Data.Len());

        // save the source file path and timestamp
        ExampleData->SourceFilePath = UAssetImportData::SanitizeImportFilename(CurrentFilename, ExampleData->GetOutermost());
    }

    return EReimportResult::Succeeded;
}

And just for fun, let’s add "Reimport" to right click menu on this asset. This is also an example for how to add more actions on specific asset type. Modify ExampleDataTypeActions class:

ExampleDataTypeActions.h
class FExampleDataTypeActions : public FAssetTypeActions_Base
{
public:
    ...
    virtual bool HasActions(const TArray<UObject*>& InObjects) const override { return true; }
    virtual void GetActions(const TArray<UObject*>& InObjects, FMenuBuilder& MenuBuilder) override;

    void ExecuteReimport(TArray<TWeakObjectPtr<UExampleData>> Objects);
};
ExampleDataTypeActions.cpp
void FExampleDataTypeActions::GetActions(const TArray<UObject*>& InObjects, FMenuBuilder& MenuBuilder)
{
    auto ExampleDataImports = GetTypedWeakObjectPtrs<UExampleData>(InObjects);

    MenuBuilder.AddMenuEntry(
        FText::FromString("Reimport"),
        FText::FromString("Reimports example data."),
        FSlateIcon(),
        FUIAction(
            FExecuteAction::CreateSP(this, &FExampleDataTypeActions::ExecuteReimport, ExampleDataImports),
            FCanExecuteAction()
        )
    );
}

void FExampleDataTypeActions::ExecuteReimport(TArray<TWeakObjectPtr<UExampleData>> Objects)
{
    for (auto ObjIt = Objects.CreateConstIterator(); ObjIt; ++ObjIt)
    {
        auto Object = (*ObjIt).Get();
        if (Object)
        {
            FReimportManager::Instance()->Reimport(Object, /*bAskForNewFileIfMissing=*/true);
        }
    }
}

Now you can reimport your custom files.

020.png

Custom Editor Mode

Editor Mode is probably the most powerful tool framework in Unreal. You will get and react to all user input; you can render to viewport; you can monitor any change in the scene and get Undo/Redo events. Remember you can enter a mode and paint foliage over objects? You can do the same degree of stuff in custom editor mode. Editor Mode has dedicated section in UI layout, and you can customize the widget here as well.

021.png

Here as an example, we will create an editor mode to do a simple task. We have an actor "ExampleTargetPoint" inherit from "TargetPoint", with a list of locations. In this editor mode we want to visualize those points. You can create new points or delete points. You can also move points around as moving normal objects. Note this is not the best way for this functionality (you can use MakeEditWidget in UPROPERTY to do this easily), but rather as a way to demonstrate how to set it up and what you can potentially do.

Setup Editor Mode

First we need to create an icon for our editor mode. We make an 40x40 PNG file as \Content\EditorResources\IconExampleEditorMode.png

Then add the following files in editor module:

022.png

SExampleEdModeWidget is the widget we use in "Modes" panel. Here we will just create a simple one for now. We also include a commonly used util function to get EdMode object.

SExampleEdModeWidget.h
#pragma once
#include "SlateApplication.h"

class SExampleEdModeWidget : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SExampleEdModeWidget) {}
    SLATE_END_ARGS();

    void Construct(const FArguments& InArgs);

    // Util Functions
    class FExampleEdMode* GetEdMode() const;
};
SExampleEdModeWidget.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ExampleEdMode.h"
#include "SExampleEdModeWidget.h"

void SExampleEdModeWidget::Construct(const FArguments& InArgs)
{
    ChildSlot
    [
        SNew(SScrollBox)
        + SScrollBox::Slot()
        .VAlign(VAlign_Top)
        .Padding(5.f)
        [
            SNew(STextBlock)
            .Text(FText::FromString(TEXT("This is a editor mode example.")))
        ]
    ];
}

FExampleEdMode* SExampleEdModeWidget::GetEdMode() const
{
    return (FExampleEdMode*)GLevelEditorModeTools().GetActiveMode(FExampleEdMode::EM_Example);
}

ExampleEdModeToolkit is a middle layer between EdMode and its widget:

ExampleEdModeToolkit.h
#pragma once
#include "BaseToolkit.h"
#include "ExampleEdMode.h"
#include "SExampleEdModeWidget.h"

class FExampleEdModeToolkit: public FModeToolkit
{
public:
    FExampleEdModeToolkit()
    {
        SAssignNew(ExampleEdModeWidget, SExampleEdModeWidget);
    }

    /** IToolkit interface */
    virtual FName GetToolkitFName() const override { return FName("ExampleEdMode"); }
    virtual FText GetBaseToolkitName() const override { return NSLOCTEXT("BuilderModeToolkit", "DisplayName", "Builder"); }
    virtual class FEdMode* GetEditorMode() const override { return GLevelEditorModeTools().GetActiveMode(FExampleEdMode::EM_Example); }
    virtual TSharedPtr<class SWidget> GetInlineContent() const override { return ExampleEdModeWidget; }

private:
    TSharedPtr<SExampleEdModeWidget> ExampleEdModeWidget;
};

Then for the main class ExampleEdMode. Since we are only try to set it up, we will leave it mostly empty, only setting up its ID and create toolkit object. We will fill it in heavily in the next section.

ExampleEdMode.h
#pragma once
#include "EditorModes.h"

class FExampleEdMode : public FEdMode
{
public:
    const static FEditorModeID EM_Example;

    // FEdMode interface
    virtual void Enter() override;
    virtual void Exit() override;
};
ExampleEdMode.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "Editor/UnrealEd/Public/Toolkits/ToolkitManager.h"
#include "ScopedTransaction.h"
#include "ExampleEdModeToolkit.h"
#include "ExampleEdMode.h"

const FEditorModeID FExampleEdMode::EM_Example(TEXT("EM_Example"));

void FExampleEdMode::Enter()
{
    FEdMode::Enter();

    if (!Toolkit.IsValid())
    {
        Toolkit = MakeShareable(new FExampleEdModeToolkit);
        Toolkit->Init(Owner->GetToolkitHost());
    }
}

void FExampleEdMode::Exit()
{
    FToolkitManager::Get().CloseToolkit(Toolkit.ToSharedRef());
    Toolkit.Reset();

    FEdMode::Exit();
}

As other tools, we need a tool class to handle registration. Here we need to register both editor mode and its icon.

ExampleEdModeTool.h
#pragma once
#include "ToolExampleEditor/ExampleTabToolBase.h"

class ExampleEdModeTool : public FExampleTabToolBase
{
public:
    virtual void OnStartupModule() override;
    virtual void OnShutdownModule() override;

    virtual ~ExampleEdModeTool() {}
private:
    static TSharedPtr< class FSlateStyleSet > StyleSet;

    void RegisterStyleSet();
    void UnregisterStyleSet();

    void RegisterEditorMode();
    void UnregisterEditorMode();
};
ExampleEdModeTool.cpp
#include "ToolExampleEditor/ToolExampleEditor.h"
#include "ExampleEdModeTool.h"
#include "ExampleEdMode.h"

#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush(StyleSet->RootToContentDir(RelativePath, TEXT(".png")), __VA_ARGS__)

TSharedPtr< FSlateStyleSet > ExampleEdModeTool::StyleSet = nullptr;

void ExampleEdModeTool::OnStartupModule()
{
    RegisterStyleSet();
    RegisterEditorMode();
}

void ExampleEdModeTool::OnShutdownModule()
{
    UnregisterStyleSet();
    UnregisterEditorMode();
}

void ExampleEdModeTool::RegisterStyleSet()
{
    // Const icon sizes
    const FVector2D Icon20x20(20.0f, 20.0f);
    const FVector2D Icon40x40(40.0f, 40.0f);

    // Only register once
    if (StyleSet.IsValid())
    {
        return;
    }

    StyleSet = MakeShareable(new FSlateStyleSet("ExampleEdModeToolStyle"));
    StyleSet->SetContentRoot(FPaths::GameDir() / TEXT("Content/EditorResources"));
    StyleSet->SetCoreContentRoot(FPaths::GameDir() / TEXT("Content/EditorResources"));

    // Spline editor
    {
        StyleSet->Set("ExampleEdMode", new IMAGE_BRUSH(TEXT("IconExampleEditorMode"), Icon40x40));
        StyleSet->Set("ExampleEdMode.Small", new IMAGE_BRUSH(TEXT("IconExampleEditorMode"), Icon20x20));
    }

    FSlateStyleRegistry::RegisterSlateStyle(*StyleSet.Get());
}

void ExampleEdModeTool::UnregisterStyleSet()
{
    if (StyleSet.IsValid())
    {
        FSlateStyleRegistry::UnRegisterSlateStyle(*StyleSet.Get());
        ensure(StyleSet.IsUnique());
        StyleSet.Reset();
    }
}

void ExampleEdModeTool::RegisterEditorMode()
{
    FEditorModeRegistry::Get().RegisterMode<FExampleEdMode>(
        FExampleEdMode::EM_Example,
        FText::FromString("Example Editor Mode"),
        FSlateIcon(StyleSet->GetStyleSetName(), "ExampleEdMode", "ExampleEdMode.Small"),
        true, 500
        );
}

void ExampleEdModeTool::UnregisterEditorMode()
{
    FEditorModeRegistry::Get().UnregisterMode(FExampleEdMode::EM_Example);
}

#undef IMAGE_BRUSH

Finally as usual, we add the tool to editor module FToolExampleEditor::AddModuleListeners:

ToolExampleEditor.cpp
ModuleListeners.Add(MakeShareable(new ExampleEdModeTool));

Now you should see our custom editor mode show up in "Modes" panel.

023.png

Render and Click

With the basic framework ready, we can actually start implementing tool logic. First we make ExampleTargetPoint class in game module. This actor holds points data, and is what our tool will be operating on. Again remember to export the class with TOOLEXAMPLE_API.

024.png
ExampleTargetPoint.h
#pragma once
#include "Engine/Targetpoint.h"
#include "ExampleTargetPoint.generated.h"

UCLASS()
class TOOLEXAMPLE_API AExampleTargetPoint : public ATargetPoint
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, Category = "Points")
    TArray<FVector> Points;
};

Now we modify ExampleEdMode to add functions to add point, remove point, and select point. We also save our current selection in variable, here we use weak object pointer to handle the case if the actor is removed.

For adding point, we only allow that when you have exactly on ExampleTargetPoint actor selected in editor. For removing point, we simply remove the current selected point if there is any. If you select any point, we will deselect all actors and select the actor associated with that point.

Note that we put FScopedTransaction, and called Modify() function whenever we modify data we need to save. This will make sure undo/redo is properly handled.

ExampleEdMode.h
...
class AExampleTargetPoint;

class FExampleEdMode : public FEdMode
{
public:
    ...
    void AddPoint();
    bool CanAddPoint() const;
    void RemovePoint();
    bool CanRemovePoint() const;
    bool HasValidSelection() const;
    void SelectPoint(AExampleTargetPoint* actor, int32 index);

    TWeakObjectPtr<AExampleTargetPoint> currentSelectedTarget;
    int32 currentSelectedIndex = -1;
};
ExampleEdMode.cpp
void FExampleEdMode::Enter()
{
    ...

    // reset
    currentSelectedTarget = nullptr;
    currentSelectedIndex = -1;
}

AExampleTargetPoint* GetSelectedTargetPointActor()
{
    TArray<UObject*> selectedObjects;
    GEditor->GetSelectedActors()->GetSelectedObjects(selectedObjects);
    if (selectedObjects.Num() == 1)
    {
        return Cast<AExampleTargetPoint>(selectedObjects[0]);
    }
    return nullptr;
}

void FExampleEdMode::AddPoint()
{
    AExampleTargetPoint* actor = GetSelectedTargetPointActor();
    if (actor)
    {
        const FScopedTransaction Transaction(FText::FromString("Add Point"));

        // add new point, slightly in front of camera
        FEditorViewportClient* client = (FEditorViewportClient*)GEditor->GetActiveViewport()->GetClient();
        FVector newPoint = client->GetViewLocation() + client->GetViewRotation().Vector() * 50.f;
        actor->Modify();
        actor->Points.Add(newPoint);
        // auto select this new point
        SelectPoint(actor, actor->Points.Num() - 1);
    }
}

bool FExampleEdMode::CanAddPoint() const
{
    return GetSelectedTargetPointActor() != nullptr;
}

void FExampleEdMode::RemovePoint()
{
    if (HasValidSelection())
    {
        const FScopedTransaction Transaction(FText::FromString("Remove Point"));

        currentSelectedTarget->Modify();
        currentSelectedTarget->Points.RemoveAt(currentSelectedIndex);
        // deselect the point
        SelectPoint(nullptr, -1);
    }
}

bool FExampleEdMode::CanRemovePoint() const
{
    return HasValidSelection();
}

bool FExampleEdMode::HasValidSelection() const
{
    return currentSelectedTarget.IsValid() && currentSelectedIndex >= 0 && currentSelectedIndex < currentSelectedTarget->Points.Num();
}

void FExampleEdMode::SelectPoint(AExampleTargetPoint* actor, int32 index)
{
    currentSelectedTarget = actor;
    currentSelectedIndex = index;

    // select this actor only
    if (currentSelectedTarget.IsValid())
    {
        GEditor->SelectNone(true, true);
        GEditor->SelectActor(currentSelectedTarget.Get(), true, true);
    }
}

Now we have functionality ready, we still need to hook it up with UI. Modify to SExampleEdModeWidget add "Add" and "Remove" button, and we will check "CanAddPoint" and "CanRemovePoint" to determine if the button should be enabled.

SExampleEdModeWidget.h
class SExampleEdModeWidget : public SCompoundWidget
{
public:
    ...
    FReply OnAddPoint();
    bool CanAddPoint() const;
    FReply OnRemovePoint();
    bool CanRemovePoint() const;
};
SExampleEdModeWidget.cpp
void SExampleEdModeWidget::Construct(const FArguments& InArgs)
{
    ChildSlot
    [
        SNew(SScrollBox)
        + SScrollBox::Slot()
        .VAlign(VAlign_Top)
        .Padding(5.f)
        [
            SNew(SVerticalBox)
            + SVerticalBox::Slot()
            .AutoHeight()
            .Padding(0.f, 5.f, 0.f, 0.f)
            [
                SNew(STextBlock)
                .Text(FText::FromString(TEXT("This is a editor mode example.")))
            ]
            + SVerticalBox::Slot()
            .AutoHeight()
            .Padding(0.f, 5.f, 0.f, 0.f)
            [
                SNew(SHorizontalBox)
                + SHorizontalBox::Slot()
                .AutoWidth()
                .Padding(2, 0, 0, 0)
                .VAlign(VAlign_Center)
                [
                    SNew(SButton)
                    .Text(FText::FromString("Add"))
                    .OnClicked(this, &SExampleEdModeWidget::OnAddPoint)
                    .IsEnabled(this, &SExampleEdModeWidget::CanAddPoint)
                ]
                + SHorizontalBox::Slot()
                .AutoWidth()
                .VAlign(VAlign_Center)
                .Padding(0, 0, 2, 0)
                [
                    SNew(SButton)
                    .Text(FText::FromString("Remove"))
                    .OnClicked(this, &SExampleEdModeWidget::OnRemovePoint)
                    .IsEnabled(this, &SExampleEdModeWidget::CanRemovePoint)
                ]
            ]
        ]
    ];
}

FReply SExampleEdModeWidget::OnAddPoint()
{
    GetEdMode()->AddPoint();
    return FReply::Handled();
}

bool SExampleEdModeWidget::CanAddPoint() const
{
    return GetEdMode()->CanAddPoint();
}

FReply SExampleEdModeWidget::OnRemovePoint()
{
    GetEdMode()->RemovePoint();
    return FReply::Handled();
}

bool SExampleEdModeWidget::CanRemovePoint() const
{
    return GetEdMode()->CanRemovePoint();
}

Now if you launch the editor, you should be able to drag in an "Example Target Point", switch to our editor mode, select that target point and add new points from the editor mode UI. However it is not visualized in the viewport yet, and you cannot click and select point. We will work on that next.

To be able to click in editor and select something, we need to define a HitProxy struct. When we render the points, we render with this hit proxy along with some data attached to it. Then when we get the click event, we can retrieve those data back from the proxy and know what we clicked on.

Back to ExampleEdMode, we define HExamplePointProxy with a reference object (the ExampleTargetPoint actor) and the point index, and we add Render and HandleClick override function.

ExampleEdMode.h
struct HExamplePointProxy : public HHitProxy
{
    DECLARE_HIT_PROXY();

    HExamplePointProxy(UObject* InRefObject, int32 InIndex)
        : HHitProxy(HPP_UI), RefObject(InRefObject), Index(InIndex)
    {}

    UObject* RefObject;
    int32 Index;
};

class FExampleEdMode : public FEdMode
{
public:
    ...
    virtual void Render(const FSceneView* View, FViewport* Viewport, FPrimitiveDrawInterface* PDI) override;
    virtual bool HandleClick(FEditorViewportClient* InViewportClient, HHitProxy *HitProxy, const FViewportClick &Click) override;
};

Then in cpp file, we use macro IMPLEMENT_HIT_PROXY to implement the proxy. In Render we simply loops through all ExampleTargetPoint actor and draw all the points (and a line to the actor itself), we choose a different color if this is the current selected point. We set hit proxy for each point before drawing and clears it immediately afterwards (this is important so the proxy doesn’t leak through to other draws). In HandleClick, we test hit proxy and select point if we have a valid hit. We don’t check mouse button here, so you can select with left/right/middle click.

ExampleEdMode.cpp
IMPLEMENT_HIT_PROXY(HExamplePointProxy, HHitProxy);
...

void FExampleEdMode::Render(const FSceneView* View, FViewport* Viewport, FPrimitiveDrawInterface* PDI)
{
    const FColor normalColor(200, 200, 200);
    const FColor selectedColor(255, 128, 0);

    UWorld* World = GetWorld();
    for (TActorIterator<AExampleTargetPoint> It(World); It; ++It)
    {
        AExampleTargetPoint* actor = (*It);
        if (actor)
        {
            FVector actorLoc = actor->GetActorLocation();
            for (int i = 0; i < actor->Points.Num(); ++i)
            {
                bool bSelected = (actor == currentSelectedTarget && i == currentSelectedIndex);
                const FColor& color = bSelected ? selectedColor : normalColor;
                // set hit proxy and draw
                PDI->SetHitProxy(new HExamplePointProxy(actor, i));
                PDI->DrawPoint(actor->Points[i], color, 15.f, SDPG_Foreground);
                PDI->DrawLine(actor->Points[i], actorLoc, color, SDPG_Foreground);
                PDI->SetHitProxy(NULL);
            }
        }
    }

    FEdMode::Render(View, Viewport, PDI);
}

bool FExampleEdMode::HandleClick(FEditorViewportClient* InViewportClient, HHitProxy *HitProxy, const FViewportClick &Click)
{
    bool isHandled = false;

    if (HitProxy)
    {
        if (HitProxy->IsA(HExamplePointProxy::StaticGetType()))
        {
            isHandled = true;
            HExamplePointProxy* examplePointProxy = (HExamplePointProxy*)HitProxy;
            AExampleTargetPoint* actor = Cast<AExampleTargetPoint>(examplePointProxy->RefObject);
            int32 index = examplePointProxy->Index;
            if (actor && index >= 0 && index < actor->Points.Num())
            {
                SelectPoint(actor, index);
            }
        }
    }

    return isHandled;
}

With all of these you can start adding/removing points in the editor:

025.png

Use Transform Widget

The next mission is to be able to move point around in editor like moving any other actor. Go back to ExampleEdMode, this time we need to add support for custom transform widget, and handle InputDelta event. In InputDelta function, we don’t use FScopedTransaction because undo/redo is already handled for this function. We still need to call Modify() though.

ExampleEdMode.h
...
class FExampleEdMode : public FEdMode
{
public:
    ...
    virtual bool InputDelta(FEditorViewportClient* InViewportClient, FViewport* InViewport, FVector& InDrag, FRotator& InRot, FVector& InScale) override;
    virtual bool ShowModeWidgets() const override;
    virtual bool ShouldDrawWidget() const override;
    virtual bool UsesTransformWidget() const override;
    virtual FVector GetWidgetLocation() const override;
};
ExampleEdMode.cpp
bool FExampleEdMode::InputDelta(FEditorViewportClient* InViewportClient, FViewport* InViewport, FVector& InDrag, FRotator& InRot, FVector& InScale)
{
    if (InViewportClient->GetCurrentWidgetAxis() == EAxisList::None)
    {
        return false;
    }

    if (HasValidSelection())
    {
        if (!InDrag.IsZero())
        {
            currentSelectedTarget->Modify();
            currentSelectedTarget->Points[currentSelectedIndex] += InDrag;
        }
        return true;
    }

    return false;
}

bool FExampleEdMode::ShowModeWidgets() const
{
    return true;
}

bool FExampleEdMode::ShouldDrawWidget() const
{
    return true;
}

bool FExampleEdMode::UsesTransformWidget() const
{
    return true;
}

FVector FExampleEdMode::GetWidgetLocation() const
{
    if (HasValidSelection())
    {
        return currentSelectedTarget->Points[currentSelectedIndex];
    }
    return FEdMode::GetWidgetLocation();
}

Now you should have a transform widget to move your points around:

026.png
virtual bool GetCustomDrawingCoordinateSystem(FMatrix& InMatrix, void* InData) override;
virtual bool GetCustomInputCoordinateSystem(FMatrix& InMatrix, void* InData) override;

Key input support, right click menu, and others

Next we will add some other common features: when we have a point selected, we want to hit delete button and remove it. Also we want to have a menu generated when you right click on a point, showing the point index, and an option to delete it.

Remember in the "Menu Tool" tutorial, in order to make a menu, we would need a UI command list, here we will do the same thing. We also override InputKey function to handle input. Though we can simply call functions based on which key is pressed, since we have the same functionality in the menu, we will route the input through the UI command list instead. (when we define UI Commands, we pass in a key in FInputGesture)

Finally we will modify HandleClick function to generate context menu when we right click on a point.

ExampleEdMode.h
...
class FExampleEdMode : public FEdMode
{
public:
    ...
    FExampleEdMode();
    ~FExampleEdMode();

    virtual bool HandleClick(FEditorViewportClient* InViewportClient, HHitProxy *HitProxy, const FViewportClick &Click) override;

    TSharedPtr<FUICommandList> ExampleEdModeActions;
    void MapCommands();
    TSharedPtr<SWidget> GenerateContextMenu(FEditorViewportClient* InViewportClient) const;
};
ExampleEdMode.cpp
class ExampleEditorCommands : public TCommands<ExampleEditorCommands>
{
public:
    ExampleEditorCommands() : TCommands <ExampleEditorCommands>
        (
            "ExampleEditor",    // Context name for fast lookup
            FText::FromString(TEXT("Example Editor")),  // context name for displaying
            NAME_None,  // Parent
            FEditorStyle::GetStyleSetName()
            )
    {
    }

#define LOCTEXT_NAMESPACE ""
    virtual void RegisterCommands() override
    {
        UI_COMMAND(DeletePoint, "Delete Point", "Delete the currently selected point.", EUserInterfaceActionType::Button, FInputGesture(EKeys::Delete));
    }
#undef LOCTEXT_NAMESPACE

public:
    TSharedPtr<FUICommandInfo> DeletePoint;
};


FExampleEdMode::FExampleEdMode()
{
    ExampleEditorCommands::Register();
    ExampleEdModeActions = MakeShareable(new FUICommandList);
}

FExampleEdMode::~FExampleEdMode()
{
    ExampleEditorCommands::Unregister();
}

void FExampleEdMode::MapCommands()
{
    const auto& Commands = ExampleEditorCommands::Get();

    ExampleEdModeActions->MapAction(
        Commands.DeletePoint,
        FExecuteAction::CreateSP(this, &FExampleEdMode::RemovePoint),
        FCanExecuteAction::CreateSP(this, &FExampleEdMode::CanRemovePoint));
}

bool FExampleEdMode::InputKey(FEditorViewportClient* ViewportClient, FViewport* Viewport, FKey Key, EInputEvent Event)
{
    bool isHandled = false;

    if (!isHandled && Event == IE_Pressed)
    {
        isHandled = ExampleEdModeActions->ProcessCommandBindings(Key, FSlateApplication::Get().GetModifierKeys(), false);
    }

    return isHandled;
}

TSharedPtr<SWidget> FExampleEdMode::GenerateContextMenu(FEditorViewportClient* InViewportClient) const
{
    FMenuBuilder MenuBuilder(true, NULL);

    MenuBuilder.PushCommandList(ExampleEdModeActions.ToSharedRef());
    MenuBuilder.BeginSection("Example Section");
    if (HasValidSelection())
    {
        // add label for point index
        TSharedRef<SWidget> LabelWidget =
            SNew(STextBlock)
            .Text(FText::FromString(FString::FromInt(currentSelectedIndex)))
            .ColorAndOpacity(FLinearColor::Green);
        MenuBuilder.AddWidget(LabelWidget, FText::FromString(TEXT("Point Index: ")));
        MenuBuilder.AddMenuSeparator();
        // add delete point entry
        MenuBuilder.AddMenuEntry(ExampleEditorCommands::Get().DeletePoint);
    }
    MenuBuilder.EndSection();
    MenuBuilder.PopCommandList();

    TSharedPtr<SWidget> MenuWidget = MenuBuilder.MakeWidget();
    return MenuWidget;
}


bool FExampleEdMode::HandleClick(FEditorViewportClient* InViewportClient, HHitProxy *HitProxy, const FViewportClick &Click)
{
    ...

    if (HitProxy && isHandled && Click.GetKey() == EKeys::RightMouseButton)
    {
        TSharedPtr<SWidget> MenuWidget = GenerateContextMenu(InViewportClient);
        if (MenuWidget.IsValid())
        {
            FSlateApplication::Get().PushMenu(
                Owner->GetToolkitHost()->GetParentWidget(),
                FWidgetPath(),
                MenuWidget.ToSharedRef(),
                FSlateApplication::Get().GetCursorPos(),
                FPopupTransitionEffect(FPopupTransitionEffect::ContextMenu));
        }
    }

    return isHandled;
}

The following is the result:

027.png

There are other virtual functions from FEdMode that can be very helpful. I’ll list some of them here:

    virtual void Tick(FEditorViewportClient* ViewportClient, float DeltaTime) override;
    virtual bool CapturedMouseMove(FEditorViewportClient* InViewportClient, FViewport* InViewport, int32 InMouseX, int32 InMouseY) override;
    virtual bool StartTracking(FEditorViewportClient* InViewportClient, FViewport* InViewport) override;
    virtual bool EndTracking(FEditorViewportClient* InViewportClient, FViewport* InViewport) override;
    virtual bool HandleClick(FEditorViewportClient* InViewportClient, HHitProxy *HitProxy, const FViewportClick &Click) override;
    virtual void PostUndo() override;
    virtual void ActorsDuplicatedNotify(TArray<AActor*>& PreDuplicateSelection, TArray<AActor*>& PostDuplicateSelection, bool bOffsetLocations) override;
    virtual void ActorMoveNotify() override;
    virtual void ActorSelectionChangeNotify() override;
    virtual void MapChangeNotify() override;
    virtual void SelectionChanged() override;

Custom Project Settings

Remember you can you go to Edit → Project Settings in Unreal editor to change various game/editor settings? You can add your custom settings to this window as well.

First we create a settings object. In this example we will create it in editor module, you can create in game module as well, just remember to export it with proper macro. In the UCLASS macro, we need specify which .ini file to write to. You can use existing .ini file like "Game" or "Editor". In this case we want this setting to be per user and not shared on source control, so we create a new ini file. For each UPROPERTY that you want to include in the settings, mark it with "config".

028.png
ExampleSettings.h
#pragma once
#include "ExampleSettings.generated.h"

UCLASS(config = EditorUserSettings, defaultconfig)
class UExampleSettings : public UObject
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, config, Category = Test)
    bool bTest = false;
};
ToolExampleEditor.cpp
...
#include "ISettingsModule.h"
#include "Developer/Settings/Public/ISettingsContainer.h"
#include "CustomProjectSettings/ExampleSettings.h"

void FToolExampleEditor::StartupModule()
{
    ...
    // register settings:
    {
        ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings");
        if (SettingsModule)
        {
            TSharedPtr<ISettingsContainer> ProjectSettingsContainer = SettingsModule->GetContainer("Project");
            ProjectSettingsContainer->DescribeCategory("ExampleCategory", FText::FromString("Example Category"), FText::FromString("Example settings description text here"));

            SettingsModule->RegisterSettings("Project", "ExampleCategory", "ExampleSettings",
                FText::FromString("Example Settings"),
                FText::FromString("Configure Example Settings"),
                GetMutableDefault<UExampleSettings>()
            );
        }
    }

    IExampleModuleInterface::StartupModule();
}

void FToolExampleEditor::ShutdownModule()
{
    ...
    // unregister settings
    ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings");
    if (SettingsModule)
    {
        SettingsModule->UnregisterSettings("Project", "ExampleCategory", "ExampleSettings");
    }

    IExampleModuleInterface::ShutdownModule();
}

Now you should see your custom settings in "Project Settings" window. And when you change it, you should see DefaultEditorUserSettings.ini created in \ToolExample\Config

029.png

To get access to this settings, do the following:

const UExampleSettings* ExampleSettings = GetDefault<UExampleSettings>();
if(ExampleSettings && ExampleSettings->bTest)
    // do something

Tricks

Use Widget Reflector

The best way to learn SLATE and Unreal tools, is to use Widget Reflector. In Window → Developer Tool → Widget Reflector to launch the reflector. Click on "Pick Live Widget" and mouse over the widget you want to see, then hit "ESC" to freeze.

For example we can mouse over our editor mode widget, and you can see the structure showing in the reflector window. You can click on the file and it will take you to the exact place that widget is constructed. This is powerful tool to debug your widget or to learn how Unreal build their widget.

030.png

Is my tool running in the editor or game?

There 3 conditions that your tool is running:

  1. Editor: game not started, you can do all normal editing.

  2. Game: game started, cannot do any editing.

  3. Simulate: either hit “Simulate” or hit “Play” then “Eject”, game started and you can do limited editing. Here is how you can determine which state you are in:

Editor

Game

Simulate

FApp::IsGame()

false

true

true

Cast<UEditorEngine>(GEngine)→bIsSimulatingInEditor

false

false

true

Note: this do NOT work in SLATE call (any UI tick for example), because that is in SLATE world.

Useful UPROPERTY() meta marker

  • MakeEditWidget: If you just need to visualize a point in the level and be able to drag it around, this is the quick way to do it. It works for FVector or FTransform, and it works with TArray of those as well.
    example: UPROPERTY(meta = (MakeEditWidget = true))

  • DisplayName, ToolTip: Useful if you want to have a different display name than the variable name; or if you want add a mouse over tooltip. There are plenty of examples in Unreal code base.

  • ClampMin, ClampMax, UIMin, UIMax: You can specify a range for the value that can be input for this field.
    example: UPROPERTY(meta = (ClampMin = "0", ClampMax = "180"))

  • EditCondition: You can specify a bool to determine whether this field is editable.
    example: UPROPERTY(meta = (EditCondition = "bIsThisFieldEnabled")))

For a complete list, search for ObjectMacros.h in Unreal code base.

Make custom Animation Blueprint Node

To make a custom Animation Blueprint Node, you need to first inherit from FAnimNode_Base class in game module, this class will process animation pose at runtime.

Then in the editor module, inherit from UAnimGraphNode_Base class, and define how you want this node to be in editor.

Debug Draw Tricks

  • Easy way to draw circle/box/sphere
    FPrimitiveDrawInterface only provides basic draw methods (DrawSprite, DrawPoint, DrawLine, DrawMesh). However Unreal already has a collection of “advanced” draw methods for their own use. Defined in “PrimitiveDrawingUtils.cpp” and declared in “SceneManagement.h”. Check out “PrimitiveDrawingUtils.cpp” for details. Necessary files should already be included, so just call “DrawCircle” or “DrawBox”.

  • Draw point with world space size
    The default FPrimitiveDrawInterface::DrawPoint function will only draw point with screen space size, but sometimes you want to give it a world space size, here’s how you can do it:

void DrawPointWS (
    FPrimitiveDrawInterface* PDI,
    const FVector& Position,
    const FLinearColor& Color,
    float PointSize,
    uint8 DepthPriorityGroup,
    bool bScreenSpaceSize
)
{
    float ScaledPointSize = PointSize;
    if (!bScreenSpaceSize)
    {
        FVector PositionVS = PDI->View->ViewMatrices.GetViewMatrix().TransformPosition(Position);
        float factor = FMath::Max(FMath::Abs(PositionVS.Z), 0.001f);
        ScaledPointSize /= factor;
        ScaledPointSize *= PDI->View->ViewRect.Width();
    }
    PDI->DrawPoint(Position, Color, ScaledPointSize, DepthPriorityGroup);
}

Other Tricks for Editor Mode

  • It is quite common you need a viewport client to do something, and not all functions has viewport client passed in. Here is the call you can get that from anywhere:

FEditorViewportClient* client = (FEditorViewportClient*)GEditor->GetActiveViewport()->GetClient();
  • It is also quite common you want to refresh rendering for the whole viewport after the user did some edit in your tool. Use the following call:

GEditor->RedrawAllViewports(true);
  • If the Editor Mode is not responding, or lagging behind, make sure you have "Realtime" checked in viewport.

031.png
comments powered by Disqus