Search Docs
Loading...
Skip to content

Video Timeline

Customize the video timeline — the playback and arrangement surface below the canvas — by passing a Timeline.Configuration that adjusts its add-content buttons, header, height, and expanded state.

Video Timeline

8 mins
estimated time
GitHub

Timeline Architecture#

Timeline is both the component you mount and the namespace for everything that customizes it. It renders three configurable surfaces:

  • The header above the ruler, holding the timecode, playback controls, and the expand/collapse toggle.
  • The “Add Clip” button in the background lane, after the last clip of the main sequence.
  • The “Add Audio” button at the foot of the overlay stack, below the overlay tracks.

Key types:

  • Timeline.Configuration — the value you pass to the component. Built with a closure over a Builder that exposes addClip, addAudio, height, header(_:), and modifyHeader(_:).
  • Timeline.Item — the protocol every header item and lane button conforms to. Timeline.Custom wraps any SwiftUI view as one.
  • Timeline.ItemContext — passed to every closure. It exposes the engine, the eventHandler, the configured assetLibrary, the editor state, and the verticalSizeClass.
  • Timeline.AddClipOption / Timeline.AddAudioOption — the entries the two lane buttons offer.

The item, option, and visibility closures are evaluated per render, so what the timeline shows can depend on the context — whether an export is running, the editor’s view mode, or the vertical size class.

Configuration#

Mount the timeline as the editor’s bottom-panel content and hand it a configuration. These examples build on GuideEditorConfiguration, a minimal baseline the iOS guides repository ships, which sets only a navigation bar. Substitute your own configuration class — builder.bottomPanel { … } is available on every EditorConfiguration. The Configuration guide covers how EditorConfiguration and EngineSettings set up the editor as a whole.

builder.bottomPanel { bottomPanel in
// Built once here rather than inside `content`, which re-renders with the editor.
let configuration = timelineConfiguration
bottomPanel.content { context in
Timeline(
context: context,
configuration: configuration,
isExpanded: $isTimelineExpanded,
)
}
}

The timeline renders its tracks and nothing else by default: no header, and no lane buttons. Each section below turns one of those surfaces on, and the video starter kit does the same. The isExpanded binding is optional and covered in Expanded State.

Build the configuration outside the content closure. The bottom panel evaluates content on every render, and the Timeline.Configuration initializer runs its configure closure each time it is called.

Device Permissions#

The default add-content options reach two capabilities iOS gates behind a privacy prompt. Timeline.AddClipOption.camera() opens the CE.SDK camera, and Timeline.AddAudioOption.voiceover() records through the microphone — in a video scene the camera captures audio too. Add both keys to your app’s Info.plist with user-facing descriptions. iOS terminates the app the first time CE.SDK requests access if a key is missing.

<key>NSCameraUsageDescription</key>
<string>We use the camera to record clips for your video projects.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We use the microphone to record voiceovers and sound for your video projects.</string>

The camera and recording sheets handle the permission prompts themselves and surface a Settings shortcut if the user denies access. Timeline.AddClipOption.photoRoll() needs no key — it uses the out-of-process system photo picker.

Add Clip Sources#

The “Add Clip” button opens a menu of sources that add to the background track. Timeline.Buttons.addClip(options:) decides which sources it offers. title and icon change the button’s own label. There are three built-in ones — camera(), library(), and photoRoll(). The button offers camera plus library by default.

The options closure is a result builder, so you list the sources you want, in display order. This example restates the two built-in sources alongside the photo picker and a custom stock-footage source:

configuration.addClip = Timeline.Buttons.addClip { _ in
// Passing options replaces the menu, so the built-in sources are restated here.
Timeline.AddClipOption.camera()
Timeline.AddClipOption.library()
Timeline.AddClipOption.photoRoll()
Timeline.AddClipOption.custom(
id: "my.package.timeline.addClip.stockFootage",
action: { context in
context.eventHandler.send(.openSheet(type: .libraryAdd { context.assetLibrary.videosTab }))
},
title: { _ in Text("Stock Footage") },
icon: { _ in Image(systemName: "film.stack") },
)
}

A custom source’s action receives the Timeline.ItemContext, so it can reach the engine, the configured asset library, and the event handler.

Every built-in source carries a stable id under Timeline.AddClipOption.ID — and Timeline.AddAudioOption.ID for the audio ones. Use them to give a replacement the same identity as the source it stands in for.

Reorder, Relabel, and Replace Behavior#

List the sources yourself to control the order or drop one. Each built-in factory takes title, icon, and action, so a source can keep its behavior while its presentation changes, or keep its presentation while its behavior changes:

Timeline.Buttons.addClip { _ in
Timeline.AddClipOption.photoRoll()
// A built-in source keeps its behavior while its label and icon change.
Timeline.AddClipOption.library(
title: { _ in Text("Media") },
icon: { _ in Image(systemName: "square.stack.fill") },
)
// A built-in source keeps its label and icon while its action is replaced.
Timeline.AddClipOption.camera(action: { context in
context.eventHandler.send(.openSheet(type: .libraryAdd { context.assetLibrary.videosTab }))
})
}

Hide a Source Conditionally#

isVisible gates a single source without removing it from the list:

Timeline.Buttons.addClip { _ in
Timeline.AddClipOption.camera(isVisible: { !$0.state.isExporting })
Timeline.AddClipOption.library()
}

One Source Instead of a Menu#

When only one source is visible, the button performs it directly on tap instead of opening a menu, and shows that source’s own label rather than the generic “Add Clip”:

Timeline.Buttons.addClip { _ in Timeline.AddClipOption.photoRoll() }

Returning no options — or options that are all hidden — hides the button entirely.

Add Audio Sources#

The “Add Audio” button works the same way. Timeline.Buttons.addAudio(options:) offers music() and voiceover() by default, and the same listing, reordering, relabeling, and single-source rules apply:

configuration.addAudio = Timeline.Buttons.addAudio { _ in
Timeline.AddAudioOption.music()
Timeline.AddAudioOption.voiceover()
Timeline.AddAudioOption.custom(
id: "my.package.timeline.addAudio.soundEffects",
action: { context in
context.eventHandler.send(.openSheet(type: .libraryAdd { context.assetLibrary.audioTab }))
},
title: { _ in Text("Sound Effects") },
icon: { _ in Image(systemName: "waveform") },
)
}

Replace the Whole Button#

Assign a Timeline.Custom to addAudio to replace the button rather than its options:

Timeline.Custom(id: "my.package.timeline.button.addAudio", content: { context in
Button {
context.eventHandler.send(.openSheet(type: .voiceover()))
} label: {
Label("Record", systemImage: "mic.circle.fill")
.font(.caption)
.fontWeight(.semibold)
}
.buttonStyle(.plain)
.padding(.horizontal)
.fixedSize(horizontal: true, vertical: false)
})

The lane sizes a replacement to one track row and gives it no intrinsic width, so claim the width the label needs with fixedSize(horizontal:vertical:). The timeline reserves its height by row count and never measures the button, so a taller one overflows the lane.

Timeline.Custom also takes isEnabled and isVisible. Both are closures over the context, and isVisible is what removes the item from layout — do not encode visibility in the content view.

Timeline Header#

Header items are grouped by Timeline.ItemPlacement — .leading, .center, or .trailing. Placement alone only groups items; Timeline.Spacer() is what pushes them apart.

Modify the Built-in Header#

The header is empty until you set one. header(_:) declares it, and modifyHeader(_:) adjusts a header that was already set — the video starter kit’s, or your own — without restating it. modifyHeader(_:) on an empty header has nothing to adjust, and its id-anchored operations throw, so declare the header first:

// The timeline renders tracks alone, so the header is declared before it is adjusted.
configuration.header { _ in
Timeline.ItemGroup(placement: .leading) {
Timeline.Labels.timecode()
Timeline.Spacer()
}
Timeline.ItemGroup(placement: .center) {
Timeline.Buttons.playPause()
}
Timeline.ItemGroup(placement: .trailing) {
Timeline.Buttons.loop()
Timeline.Spacer()
Timeline.Buttons.toggleExpanded()
}
}

Then adjust it. Four of its operations are anchored to an item id — addAfter(id:), addBefore(id:), replace(id:), and remove(id:) — and two to a placement edge: addFirst(placement:) and addLast(placement:). Header items are grouped, so the placement argument is required; the dock, inspector bar, and canvas menu take a bare addFirst/addLast instead.

configuration.modifyHeader { _, items in
items.remove(id: Timeline.Buttons.ID.loop)
items.addFirst(placement: .trailing) {
Timeline.Custom(id: "my.package.timeline.button.mute", content: { _ in
MuteButton()
})
}
}

Prefer the placement-anchored operations over addAfter(id:) and addBefore(id:) when the position only has to be “at this edge”. The id-anchored operations throw when the item they name is absent, which is what happens once a built-in item you anchored on is removed or renamed.

Declare the Header Outright#

header(_:) replaces the header with the groups you declare:

configuration.header { _ in
Timeline.ItemGroup(placement: .leading) {
Timeline.Labels.timecode()
Timeline.Spacer()
}
Timeline.ItemGroup(placement: .center) {
Timeline.Buttons.playPause()
}
Timeline.ItemGroup(placement: .trailing) {
Timeline.Spacer()
Timeline.Buttons.toggleExpanded()
}
}

The built-in items are timecode(), playPause(), loop(), toggleExpanded(), and Timeline.Spacer(). playPause, loop, and toggleExpanded each take an action that replaces the built-in behavior. playPause and loop take an icon, because neither renders any text. timecode and toggleExpanded take a whole label instead, because neither is a plain glyph. Every item takes an isEnabled and an isVisible. Pass nil — the default — to keep the built-in. toggleExpanded hides itself in a compact vertical size class by default.

Remove a Surface#

Each of the three surfaces can be removed outright. An empty header removes the player bar itself rather than leaving an empty strip, while the timeline body keeps rendering. Setting addClip or addAudio to nil removes that lane button:

configuration.addClip = nil
configuration.addAudio = nil
configuration.header { _ in }

Timeline Height#

The timeline’s height is expressed in track rows. height is a closure returning a Timeline.HeightMode, resolved per render, so the height can depend on the context. Every case counts overlay tracks: the background track is always shown and is never counted.

dynamic(maximumTracks:) is the default. The timeline hugs its tracks and grows to at most that many:

configuration.height = { _ in .dynamic(maximumTracks: 2) }

fixed(tracks:) sizes the timeline to show exactly that many tracks and stops auto-resizing. 0 still shows the background track, and negative values are clamped to 0:

configuration.height = { _ in .fixed(tracks: 2) }

Because height is a closure, it can also follow the context rather than being a constant:

configuration.height = { context in
context.verticalSizeClass == .compact ? .fixed(tracks: 1) : .dynamic(maximumTracks: 3)
}

The closure can throw, and a throwing closure resolves to the default dynamic(maximumTracks: 3) rather than surfacing the error.

A caption lane is a lane of its own rather than an overlay track. It adds a row on top of maximumTracks under dynamic, appearing with the first caption clip rather than with the caption track. Under fixed it adds nothing — fixed means fixed.

Expanded State#

By default the timeline owns its expanded state and starts expanded. Pass an isExpanded binding to own it yourself:

/// The timeline's expanded state, owned by this view rather than by the component.
@State private var isTimelineExpanded = true
Timeline(
context: context,
configuration: configuration,
isExpanded: $isTimelineExpanded,
)

The binding sets the initial state and stays in sync in both directions: writing to it expands or collapses the timeline, and the user’s own toggle writes back. Collapsing leaves the header visible and hides the tracks below it, which is also what happens while a sheet covers the timeline.

API Reference#

Methods#

Method Description
Timeline(context:configuration:isExpanded:) The timeline component, mounted as bottom-panel content
Timeline.Configuration(_:) Builds a configuration from a closure over its Builder
Timeline.Configuration.Builder.header(_:) Replaces the header with the declared ItemGroups
Timeline.Configuration.Builder.modifyHeader(_:) Adjusts the header without restating it
Timeline.Buttons.addClip(options:title:icon:) The “Add Clip” button with the given sources
Timeline.Buttons.addAudio(options:title:icon:) The “Add Audio” button with the given sources
Timeline.Labels.timecode(label:isVisible:) Header label showing the playhead position and total duration
Timeline.Buttons.playPause(action:icon:isVisible:) Header play/pause button
Timeline.Buttons.loop(action:icon:isVisible:) Header looping toggle
Timeline.Buttons.toggleExpanded(action:label:isVisible:) Header expand/collapse toggle
Timeline.Spacer(isVisible:) Flexible space between header items
Timeline.AddClipOption.camera(action:title:icon:isVisible:) Records a clip with the camera
Timeline.AddClipOption.photoRoll(action:title:icon:isVisible:) Opens the privacy-friendly photo picker
Timeline.AddClipOption.library(action:title:icon:isVisible:) Opens the asset library
Timeline.AddClipOption.custom(id:action:title:icon:isVisible:) A custom clip source
Timeline.AddAudioOption.music(action:title:icon:isVisible:) Opens the audio asset library
Timeline.AddAudioOption.voiceover(action:title:icon:isVisible:) Starts a voiceover recording
Timeline.AddAudioOption.custom(id:action:title:icon:isVisible:) A custom audio source
Timeline.Custom(id:content:isEnabled:isVisible:) Wraps a SwiftUI view as a timeline item
Timeline.ItemGroup(placement:items:) A group of header items with one placement

Properties#

Property Type Description
addClip (any Timeline.Item)? The “Add Clip” button, or nil to remove it
addAudio (any Timeline.Item)? The “Add Audio” button, or nil to remove it
height Timeline.Height Closure returning a Timeline.HeightMode — .dynamic(maximumTracks:) (default, 3) or .fixed(tracks:)
Timeline.ItemContext.engine Engine The engine of the current editor
Timeline.ItemContext.eventHandler EditorEventHandler Sends EditorEvents
Timeline.ItemContext.assetLibrary any AssetLibrary The configured asset library
Timeline.ItemContext.state EditorState Whether the editor is creating or exporting, and its view mode
Timeline.ItemContext.verticalSizeClass UserInterfaceSizeClass? The timeline’s vertical size class
Timeline.Buttons.ID.addClip EditorComponentID ID constants for the built-in buttons
Timeline.AddClipOption.ID.camera EditorComponentID ID constants for the built-in clip sources
Timeline.AddAudioOption.ID.music EditorComponentID ID constants for the built-in audio sources

Next Steps#