Almost every animator I spoke to said “There is no timeline”
There is, but on further investigation I discovered what they meant was:
“I can’t tear off and re-dock the timeline”

As a developer this didn’t make sense to me, why would you want to separate the timeline from the sequencer?
So I watched them work and found a few issues with the current timeline:

  1. On a 4k display the timeline is small and hard to precisely click on.
  2. With the sequencer on a separate display the artist has to constantly move focus between displays to scrub
  3. Animators like to work “Full-Screen” with minimal distractions.

This change will be my first in-engine change (not a plugin), partly so it can be better integrated with the sequencer, but also because it’s not worth exposing so many internals just to add one plugin.

As always, I split my requirements into “Minimal” and “Production-Ready” to ensure I don’t get off track.

Minimal Requirements:

  • Can tear off timeline
  • Display key ticks
  • Sync with the graph editor
  • Be resizable vertically
  • Be able to pan/zoom
  • Playback controls

Production-Ready Requirements

  • Support for selecting and manipulating keys
  • Visual display of audio waveform
  • Support for visual difference between blocking keys and normal keys

Implementation:

Fortunately most of the work is already done, Essentially I copied the graph editor and ripped out all the widgets I didn’t care about, then added the key tick overlay.
Let’s start with the widget itself.

We have three widgets in our panel, the timeslider itself, the time range widget and the transport controls (play/pause)
The job of this panel is to act as an interface layer between these three widgets and the TimeSliderController

/// Simple Resizable Timeline panel
class SEQUENCER_API STimelinePanel : public SCompoundWidget
{
	SLATE_BEGIN_ARGS(STimelinePanel) {}

	/// Tab Manager which owns this panel.
	SLATE_ARGUMENT(TSharedPtr<FTabManager>, TabManager)

	/// Optional Time Slider Controller which allows us to synchronize with an externally controlled Time Slider
	SLATE_ARGUMENT(TSharedPtr<ITimeSliderController>, ExternalTimeSliderController)

	SLATE_END_ARGS()

	STimelinePanel();
	~STimelinePanel();

	/// Construct the panel
	/// @param InArgs: Slate arguments
	/// @param InSequencer: The sequencer to drive
	void Construct(const FArguments& InArgs, TSharedRef<FSequencer> InSequencer);

private:
	TWeakPtr<FSequencer> WeakSequencer;
	TWeakPtr<FTabManager> WeakTabManager;

	/// Create the play/pause buttons
	TSharedRef<SWidget> MakeTransportControls();

	// below methods map the transport controls to the sequencer
	FReply OnRecord();
	FReply OnPlayForward(bool bTogglePlay);
	FReply OnPlayBackward(bool bTogglePlay);
	FReply OnStepForward();
	FReply OnStepBackward();
	FReply OnJumpToStart();
	FReply OnJumpToEnd();
	FReply OnCycleLoopMode();
	FReply SetPlaybackEnd();
	FReply SetPlaybackStart();
	FReply JumpToPreviousKey();
	FReply JumpToNextKey();
	EPlaybackMode::Type GetPlaybackMode() const;
	TSharedRef<SWidget> OnCreateTransportRecord();
	EVisibility GetRecordButtonVisibility() const;

};

The widget is fairly simple, the SequencerWidgets module already handles the tedious task of making the time slider, and we throw the created widgets into some slate boxes in our preferred arrangement.
One issue I did have was you can’t resize the TimeRangeSlider as it has a hardcoded height, so I made sure to at least align it to the bottom.

void STimelinePanel::Construct(const FArguments& InArgs, TSharedRef<FSequencer> InSequencer)
{
	WeakTabManager = InArgs._TabManager;
	WeakSequencer = InSequencer;

	// Construct the widgets, but only if a controller is connected
	ISequencerWidgetsModule& SequencerWidgets = FModuleManager::Get().LoadModuleChecked<ISequencerWidgetsModule>("SequencerWidgets");
	TSharedPtr<SWidget> TimeSlider = SNullWidget::NullWidget;
	TSharedPtr<SWidget> TimeRangeSlider = SNullWidget::NullWidget;
	if (InArgs._ExternalTimeSliderController) {
		TimeSlider = SequencerWidgets.CreateTimeSlider(InArgs._ExternalTimeSliderController.ToSharedRef(), false /*bMirrorLabels*/);
		TimeRangeSlider = SequencerWidgets.CreateTimeRangeSlider(InArgs._ExternalTimeSliderController.ToSharedRef());
	}
	// play/pause buttons
	TSharedPtr<SWidget> TransportControl = MakeTransportControls();

	ChildSlot
	[
		SNew(SBorder)
		.BorderImage(FEditorStyle::GetBrush("ToolPanel.GroupBorder"))
		.BorderBackgroundColor(FLinearColor(.50f, .50f, .50f, 1.0f))
		.Padding(0)
		.Clipping(EWidgetClipping::ClipToBounds)
		[
			SNew(SVerticalBox)
			+ SVerticalBox::Slot()
			.FillHeight(1.0f)
			[
				TimeSlider.ToSharedRef()
			]
			+ SVerticalBox::Slot()
			.AutoHeight()
			[
				SNew(SHorizontalBox)
				+ SHorizontalBox::Slot()
				.FillWidth(1.0f)
				.VAlign(EVerticalAlignment::VAlign_Bottom)
				[
					TimeRangeSlider.ToSharedRef()
				]
				+ SHorizontalBox::Slot()
				.AutoWidth()
				[
					TransportControl.ToSharedRef()
				]
			]
		]
	];
}

The transport controls allow a little more control over how they are built, for now I grabbed them all and just remapped them, it’s not worth fiddling with these until after the first round of feedback.

TSharedRef<SWidget> STimelinePanel::MakeTransportControls()
{
	FEditorWidgetsModule& EditorWidgetsModule = FModuleManager::Get().LoadModuleChecked<FEditorWidgetsModule>( "EditorWidgets" );

	FTransportControlArgs TransportControlArgs;
	{
		TransportControlArgs.OnBackwardEnd.BindSP( this, &STimelinePanel::OnJumpToStart );
		TransportControlArgs.OnBackwardStep.BindSP( this, &STimelinePanel::OnStepBackward );
		TransportControlArgs.OnForwardPlay.BindSP( this, &STimelinePanel::OnPlayForward, true );
		TransportControlArgs.OnBackwardPlay.BindSP( this, &STimelinePanel::OnPlayBackward, true );
		TransportControlArgs.OnForwardStep.BindSP( this, &STimelinePanel::OnStepForward );
		TransportControlArgs.OnForwardEnd.BindSP( this, &STimelinePanel::OnJumpToEnd );
		TransportControlArgs.OnGetPlaybackMode.BindSP( this, &STimelinePanel::GetPlaybackMode );

		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::BackwardEnd));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::BackwardStep));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::BackwardPlay));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::ForwardPlay));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(FOnMakeTransportWidget::CreateSP(this, &STimelinePanel::OnCreateTransportRecord)));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::ForwardStep));
		TransportControlArgs.WidgetsToCreate.Add(FTransportControlWidget(ETransportControlWidgetType::ForwardEnd));
		TransportControlArgs.bAreButtonsFocusable = false;
	}

	return EditorWidgetsModule.CreateTransportControl( TransportControlArgs );
}

As for the connections they all just look like this:

FReply STimelinePanel::OnRecord()
{
	if ( !WeakSequencer.IsValid() )
		return FReply::Unhandled();
	return WeakSequencer.Pin()->OnRecord();
}

In order to draw the keys we need to create a custom TimeSliderController, Because I also wanted it to sync with the graph editor I chose to derive that, but you could just as easily create a new one.
Also since I now have access to the curve editor, I can get the keys from their instead of digging through the sequences.

/// Controller for Timeline panel
class FSequencerTimelineTimeSliderController : public FSequencerCurveEditorTimeSliderController
{
public:

	FSequencerTimelineTimeSliderController(const FTimeSliderArgs& InArgs, TWeakPtr<FSequencer> InWeakSequencer, TSharedRef<FCurveEditor> InCurveEditor)
		: FSequencerCurveEditorTimeSliderController(InArgs, InWeakSequencer, InCurveEditor)
	{
	}

	virtual int32 OnPaintTimeSlider( bool bMirrorLabels, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled ) const override
	{
		// Paint the timeline
		int32 Out = FSequencerCurveEditorTimeSliderController::OnPaintTimeSlider( bMirrorLabels, AllottedGeometry, MyCullingRect, OutDrawElements, LayerId, InWidgetStyle, bParentEnabled );

		// Bail early if the curve editor is dead
		TSharedPtr<FCurveEditor> CurveEditor = WeakCurveEditor.Pin();
		if ( !CurveEditor.IsValid() )
			return Out;

		// Find all the unique keys within the currently displayed view range
		TSet<double> Frames;
		TRange<double> LocalViewRange = GetViewRange();	
		for (const TTuple<FCurveModelID, TUniquePtr<FCurveModel>>& CurvePair : CurveEditor->GetCurves())
		{
			FCurveModel* Curve = CurvePair.Value.Get();

			TArray<FKeyHandle> KeyHandles;
			Curve->GetKeys(*CurveEditor, LocalViewRange.GetLowerBoundValue(), LocalViewRange.GetUpperBoundValue(), TNumericLimits<double>::Lowest(), TNumericLimits<double>::Max(), KeyHandles);

			TArray<FKeyPosition> KeyPositions;
			KeyPositions.SetNum(KeyHandles.Num());
			Curve->GetKeyPositions(TArrayView<FKeyHandle>(KeyHandles), KeyPositions);

			for ( FKeyPosition Position : KeyPositions )
				Frames.Add(Position.InputValue);
		}

		// Draw the keys
		for ( double Frame : Frames )
			DrawKey( Frame, AllottedGeometry, OutDrawElements, LayerId );


		return Out;
	}
private:
	void DrawKey( double Frame, const FGeometry& AllottedGeometry, FSlateWindowElementList& OutDrawElements, int32 LayerId ) const
	{
		// Convert the frame number to relative view position
		TRange<double> LocalViewRange = GetViewRange();		
		FScrubRangeToScreen RangeToScreen( LocalViewRange, AllottedGeometry.Size );
		float KeyPosition = RangeToScreen.InputToLocalX(Frame);

		// Calculate the start and end of the box size
		const float KeyWidth = 2.f;
		const float KeyStart = KeyPosition - (KeyWidth / 2.0f);
		const float KeyEnd = KeyPosition + (KeyWidth / 2.0f);

		// Draw the key to the next layer
		const int32 KeyLayer = LayerId + 3;
		// Compute the key tick box, allow 9px at bottom for frame numbers
		FPaintGeometry MyGeometry =	AllottedGeometry.ToPaintGeometry( FVector2D( KeyStart, 0 ), FVector2D( KeyEnd - KeyStart, AllottedGeometry.Size.Y - 9.f ) );

		// hardcoded red keys for now
		FLinearColor KeyColor = FLinearColor::Red;

		const FSlateBrush* Brush = FEditorStyle::GetBrush( TEXT( "Sequencer.Timeline.VanillaScrubHandleDown" ) );

		FSlateDrawElement::MakeBox(
			OutDrawElements,
			KeyLayer,
			MyGeometry,
			Brush,
			ESlateDrawEffect::None,
			KeyColor
		);
	}

};

In the spirit of copying the Curve Editor, I also made a panel that wraps my STimelinePanel, though to be honest tthis could be skipped.

class SSequencerTimeline : public SCompoundWidget
{
	SLATE_BEGIN_ARGS(SSequencerTimeline)
	{}
	SLATE_END_ARGS()
public:
	void Construct(const FArguments& InArgs, TSharedRef<STimelinePanel> InEditorPanel, TSharedPtr<FSequencer> InSequencer)
	{
		WeakSequencer = InSequencer;

		ChildSlot
		[
			SNew(SVerticalBox)
			+ SVerticalBox::Slot()
			.FillHeight(1.0f)
			[
				InEditorPanel
			]
		];
	}

private:
	TWeakPtr<FSequencer> WeakSequencer;
};

Now it’s just a case of finding every reference to the CurveEditor and making a corresponding Timeline option, eg:

// Timeline
{
	// provide a toolkit host
	// so that we know where to spawn our tab into.
	check(InSequencer->GetToolkitHost().IsValid());

	// We create a custom Time Slider Controller which is just a wrapper around the actual one, but is aware of our custom bounds logic. Currently the range the
	// bar displays is tied to Sequencer timeline and not the Bounds, so we need a way of changing it to look at the Bounds but only for the Curve Editor time
	// slider controller. We want everything else to just pass through though.
	TSharedRef<ITimeSliderController> TimelineTimeSliderController = MakeShared<FSequencerTimelineTimeSliderController>(TimeSliderArgs, SequencerPtr, InSequencer->GetCurveEditor().ToSharedRef());

	TSharedRef<STimelinePanel> TimelineWidget = SNew(STimelinePanel, InSequencer)
		.ExternalTimeSliderController(TimelineTimeSliderController)
		.TabManager(InSequencer->GetToolkitHost()->GetTabManager());

	TimelinePanel = SNew(SSequencerTimeline, TimelineWidget, InSequencer);

	// Check to see if the tab is already opened due to the saved window layout.
	TSharedPtr<SDockTab> ExistingTimelineTab = InSequencer->GetToolkitHost()->GetTabManager()->FindExistingLiveTab(FTabId(SSequencer::TimelineTabName));
	if (ExistingTimelineTab)
		ExistingTimelineTab->SetContent(TimelinePanel.ToSharedRef());
}

There is a number of these around so I won’t bother listing them out, but here are the files you need to check:

Timeline Files changed


Final Result:

Custom Timeline Panel

Closing Thoughts

This was a fun project and allowed me to take a look at how Slate draws custom views compared to Qt.
This timeline could be taken much further, but the simplicity of it is the key to usability here.

Regards,
Mr MinimalEffort