Short vertical video is everywhere now, and plenty of apps want to give their own users the record, edit and share loop that TikTok made famous. Your audience might be hobby dancers, micro bloggers or would-be influencers, and they’ll want to record a few clips, add some music and post them without leaving your app.

In this article, you’ll learn how to use Swift, SwiftUI, and the IMG.LY CreativeEditor SDK to build a simple iOS app for recording, editing, and viewing short videos. This app features views providing core functionalities similar to making a post in a video-sharing platform like TikTok.

The editor is the central hub. It allows users to capture video clips and enhance their videos by adding filters, stickers, music, and other effects. CreativeEditor SDK calls the overall project a “scene” and the parts (clips, stickers, text, etc.) “blocks”. Once the scene is ready, the user can compose it into a standard video file and export it. Finally, the playback view showcases the finished projects using Apple’s standard VideoPlayer view. For recording and editing, the CreativeEditor SDK’s camera and video editor handle the heavy lifting, so you can get a working app together quickly. You can extend its features, filters, and creative options as your users demand.

Follow along to see how to capture video, enhance it, and export the finished product as a polished movie file.

Updated September 2026.

Setting Up Your Project

The quickest way to see the editor running is the Video Editor Starter Kit. It’s a complete Xcode project that opens straight into the video editor, and the rest of this tutorial builds on the configuration it ships. Clone the branch that matches the SDK version you want to use:

git clone -b v1.82.0 https://github.com/imgly/starterkit-video-editor-ios.git

To add the editor to an app you already have, include the SDK with your other package dependencies. Use the URL below with File > Add Package Dependencies... and add the IMGLYEditor product to your app target. If you also want to present the camera on its own, as we do later in this article, add the IMGLYCamera product from the same package.

https://github.com/imgly/IMGLYUI-swift

The editor needs iOS 16 or later, and resolving SDK 1.82.0 needs an Xcode version that includes Swift 6.3.1 or later. The package includes a number of different frameworks, and these are the ones you’ll use here:

  • IMGLYEditor, which contains the Editor view and the configuration API that every editor type shares.
  • IMGLYCamera, a standalone camera view. This is the same camera the editor opens from its dock.
  • IMGLYUI, an umbrella framework of all of the frameworks in the package. Before releasing your app, review and include only the components you need to keep the app size down.

The editor’s layout and behavior come from a configuration class that you add to your own project. Copy the starter kit’s StarterKit folder into your project with this command, then drag the folder into Xcode and make sure the files are added to your app target, including video-empty.scene, which has to appear under Copy Bundle Resources:

repo="starterkit-video-editor-ios"
version="1.82.0"
curl -L "https://codeload.github.com/imgly/${repo}/tar.gz/refs/heads/v${version}" | tar -xz --strip-components=1 "${repo}-${version}/StarterKit"

The folder contains VideoEditorConfiguration.swift along with a callbacks folder holding the scene setup and the export flow, and a components folder holding separate files for the dock, the navigation bar, the bottom panel, the canvas menu, and the inspector bar. Because these files are part of your codebase, you can change any of them. VideoEditorStarterKit.swift reads the license from a secrets value that only exists in the sample app, so replace secrets.licenseKey with your license key, or delete that file and present the editor from your own view as shown below. With the package resolved and the files in place, you just need to import IMGLYEditor in any of the files that present the editor. When you’re working with the underlying engine directly you’ll also want to import IMGLYEngine.

import IMGLYEditor
import IMGLYEngine
import SwiftUI

struct CreateVideoView: View {
  let settings = EngineSettings(license: "<your license key>",
                                userID: "<your unique user id>")

  var body: some View {
    NavigationStack {
      Editor(settings)
        .imgly.configuration {
          VideoEditorConfiguration()
        }
    }
  }
}

The editor puts its close, undo, redo and export buttons in the navigation bar, which is why it sits inside a NavigationStack. If you pass nil as the license, the editor runs in evaluation mode and the exported videos carry a watermark.

Asking for Permissions

Before any app can access the phone’s microphone or camera, the user must specifically give permission to record audio or video. If your app doesn’t declare why it needs these permissions, iOS terminates it the first time the camera or microphone is requested, and it probably won’t get through Apple’s app review either. The dialog requesting permissions is a system dialog and looks like this:

permission dialog

You do not have the ability to change this dialog. You can supply the reason you are asking for the permission using a key in the Info.plist file in the Xcode project. In the example above “Lets you record videos” is in the Info.plist. For the video camera you will have to ask for video and audio permission. The first step is to add a short message to the user in the Info.plist.

Info plist example

For video the key to add is NSCameraUsageDescription and for the microphone you need to add NSMicrophoneUsageDescription. Whenever your app attempts to use the camera, iOS will first check to see if the user has already granted access. If not, it will display the dialog using your entries in the Info.plist. The user might be surprised by these dialog boxes and may accidentally tap Don't Allow if they are trying to quickly launch the camera. It is better practice to set up some kind of onboarding view and secure the permissions before you need them. You might have a view that explains what you are going to ask for and then displays the permission dialog.

switch AVCaptureDevice.authorizationStatus(for: .video) {
  case .authorized:
    //the user has authorized permission!
    break
  case .denied:
    //the user has previously denied permission!
    //perhaps we should ask them again
    break
  case .restricted:
    //the user cannot grant permission!
    break
  case .notDetermined:
    //the user has never been asked for permission
    //so let's ask them now
    AVCaptureDevice.requestAccess(for: .video) { accessGranted in
      if accessGranted {
        //the user has authorized permission!
      } else {
        //the user has denied permission!
      }
    }
  @unknown default:
    break
}

The snippet above lets your app read the permission status for the video camera and ask for permission if it has not been granted. To request access to the microphone pass .audio to the AVCaptureDevice.authorizationStatus(for:) and AVCaptureDevice.requestAccess(for:) functions. The AVCaptureDevice.requestAccess(for:) command is what displays the system dialog actually requesting access. The accessGranted variable reports back to your app what the user chose. You can take care of getting the permissions any time, but if the user hasn’t answered yet, the IMG.LY camera shows the dialog the first time it opens.

With the .restricted case, there are policies on the phone that prohibit the user from granting permission to your app. In addition to asking permissions during onboarding, it is good practice to check for permission every time before you attempt to run any code that uses the camera. The user can change permissions using the Settings app on their phone at any time. If the user has denied access, the IMG.LY camera shows an alert with a shortcut to the Settings app and then closes. If you present the camera yourself, your result closure receives .failure(.permissionsMissing).

Your app will probably also want to use the photos on the user’s phone. The starter kit’s photo roll button opens the system photos picker, which needs no permission dialog because the user picks each photo or video themselves. If you switch the photo roll to full library access with PhotoRollAssetSource(engine: engine, mode: .fullLibraryAccess), you will need an entry in the Info.plist for NSPhotoLibraryUsageDescription, and the dialog appears the first time the user opens the photo roll. The starter kit registers that source in defaultLoadAssetSources in OnCreate+Video.swift. You can give your user some ability to grant that permission during onboarding. For the user’s photos, you can check the authorization status using

let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

As with the camera, the photo library has PHPhotoLibrary.requestAuthorization(for: .readWrite) but instead of just returning a “granted” or “denied” status, its completion handler receives the actual new status. In addition to the status values for the camera, the photo library may return a .limited status meaning that the user has granted permission to only some of the photos. If the user has chosen to share only some of their photos, Apple provides some views you can present so that the user can manage the photos. Any videos that your app saves to the user’s photo library will always be available when the user has chosen .limited. You can read more about how to work with permissions and the user’s photo library by reading this Apple article Delivering an Enhanced Privacy Experience in Your Photos App.

Making Video Recordings

The start of a great TikTok is the camera. When our user wants to make a new video clip with our app, they tap on the button at the bottom of the initial screen to start the creation workflow.

Here there is a decision to make. TikTok and the CreativeEditor SDK can each start with the creation controls and a video preview. When using the CreativeEditor SDK though, you can also start with some video that came from somewhere else. So if you already have some camera code you like, or if you want to start with some video clip from somewhere else, you can insert that into the editor when it launches. You do that in the onCreate callback of the editor configuration.

Editor(settings)
  .imgly.configuration {
    VideoEditorConfiguration { builder in
      builder.onCreate { engine, _ in
        let videoURL = Bundle.main.url(forResource: "dog_water", withExtension: "mov")!
        try await VideoEditorConfiguration.defaultOnCreate(
          createScene: { engine in
            try await engine.scene.create(fromVideo: videoURL)
          }
        )(engine)
      }
    }
  }

In the code above, the app reads the dog_water.mov file from the app bundle and creates the scene from it. The starter kit’s defaultOnCreate still applies the editor settings and loads the asset sources, so only the scene creation step changes.

Starting scene with a dog video

Whether starting with a video or starting with a blank canvas a user can add video clips to their creation using the embedded camera.

The TikTok camera puts several editing functions on the capture screen, while the IMG.LY camera focuses purely on recording. Both systems allow users to start and stop video recording to compile a series of clips before moving to the editing phase.

Configuring the Camera

The camera button in the starter kit’s dock is defined in Dock+Video.swift. It opens the IMG.LY camera in photo and video mode. If you’d rather use Apple’s camera, replace that button with Dock.Buttons.systemCamera(action: { $0.eventHandler.send(.addFromSystemCamera(addToBackgroundTrack: true)) }), which adds the recorded clips to the main video track like the IMG.LY camera does. When you present the camera on its own, you have more options. You can set the colors of the buttons, helpful if you have a preferred color scheme, and you can set a maximum duration for the video recording allowed.

import IMGLYCamera

let config = CameraConfiguration(
  recordingColor: .orange,
  highlightColor: .blue,
  maxTotalDuration: 30,
  allowExceedingMaxDuration: false)

Camera(settings, config: config) { result in
  switch result {
  case let .success(.capture(captures)):
    let clipURLs = captures.videos.flatMap(\.videos).map(\.url)
    print(clipURLs) //copy the clips somewhere permanent, then hand them to the editor
  case .success(.reaction):
    break
  case let .failure(error):
    print(error.localizedDescription)
  }
}

The code above sets the record button and the recording indicators to orange while recording and highlights the camera buttons in blue when they’re tapped. There is a maximum duration of 30 seconds for all videos in the collection. The camera records into a temporary location, so copy the clips somewhere permanent if you want to keep them. To open the editor with everything the user just recorded, keep the result the camera closure above receives somewhere the editor configuration can reach, then pass createScene: { engine in try await engine.createScene(from: result.get()) } to VideoEditorConfiguration.defaultOnCreate(...) in the editor’s onCreate callback, as in the dog video example. result.get() unwraps the CameraResult that createScene(from:) takes, or throws the CameraError if the recording failed. The createScene(from:) helper comes from IMGLYCamera, so import that module in the file that configures the editor.

customized camera

If your app requires further customization, you’ll probably want to make your own camera view and work with the IMG.LY Creative Engine directly.

The documentation website is a great resource for understanding how much you can add to and customize the editor and when you’ll need to drop down to the level of the engine.

Presenting the Editor

The editor and the camera are both SwiftUI views that can be presented directly or through modifiers such as sheet or fullScreenCover. The editor places its close, undo, redo and export buttons in the navigation bar, so when you trigger it from a button, embed it in a NavigationStack.

Button("Edit the Video") {
  isPresented = true
}
.fullScreenCover(isPresented: $isPresented) {
  NavigationStack {
    Editor(settings)
      .imgly.configuration {
        VideoEditorConfiguration()
      }
  }
}

Building Your Creation

compare editors

After capturing a video, the app displays an editor to complete the creation. The editing tools are in the red circles. TikTok also puts some of its editing tools on the capture screen. In the current starter kit, the dock runs along the bottom and adds clips from the photo roll or the camera, and opens overlays, text, stickers and shapes, captions, audio, voiceover and resize. When any of the elements of the scene, like your video or an audio clip, are selected, the bottom row becomes tools to manipulate that element.

How to configure and customize each of these tools is beyond the scope of this tutorial. But, a lot of the customization of the assets, fonts, blurs and even filters can be done without code. By default, the CreativeEditor SDK loads its assets from the IMG.LY CDN, which is meant for evaluation and demos. For production, host the assets on your own server by passing its URL as baseURL, or bundle them with the app. Hosting them yourself lets you push updated filters, stickers, and other assets to your users without going through the app update process. The next section shows the bundled option and links to the documentation for both.

Configuring the Editor’s Assets

The IMG.LY Engine looks for assets for stickers, vector shapes, LUT filters, color filters, color palettes, blurs, and fonts using a URL. The design of the system is that you would have your own server supporting your app. However, assets in the app bundle are also accessible by URL, so as long as the assets are in the folder format that the engine expects, it doesn’t matter whether they are local or remote.

In the documentation for this process you can find the URL of the default asset archive for each SDK version. The archive is large (about 570 MB for version 1.82.0) because it also contains sample images, videos, audio and templates. It unpacks into an assets folder, which you can rename to IMGLYAssets.bundle and add to your app target. The video editor doesn’t register the template sources, so the last command deletes those folders:

curl -O https://cdn.img.ly/packages/imgly/cesdk-swift/1.82.0/imgly-assets.zip
unzip imgly-assets.zip
mv assets IMGLYAssets.bundle
rm -rf IMGLYAssets.bundle/ly.img.templates IMGLYAssets.bundle/ly.img.templates.premium

The bundle holds a folder for each asset source, plus the fonts and emoji folders the engine reads its fallback fonts from. Each asset source folder has a content.json file containing metadata about the assets and the files that provide the actual assets. Most filters need files too: the LUT filters point at lookup-table images in the ly.img.filter.lut folder, and only the duotone entries are defined entirely in the JSON. Vectors split two ways: about half keep their geometry in the JSON as a path string, the rest point at a .blocks file in the ly.img.vector.shape/blocks folder, and every entry points at a thumbnail file in its own folder.

For example, ly.img.sticker/content.json describes the ape sticker, and its image is images/emoji/emoji_ape.svg in the same folder. You can edit these assets and add your own.

Once you’re done editing the asset bundle, you can point the editor at it when you create the engine settings:

let settings = EngineSettings(
  license: "<your license key>",
  userID: "<your unique user id>",
  baseURL: Bundle.main.url(forResource: "IMGLYAssets", withExtension: "bundle")
)

Editor(settings)
  .imgly.configuration {
    VideoEditorConfiguration()
  }

In the code above, the baseURL points at the bundle in the app instead of the remote CDN. The editor copies it into the engine’s basePath setting before the onCreate callback runs, so the starter kit’s defaultLoadAssetSources step reads each asset source’s content.json from the bundle, and the engine loads its fallback fonts and emoji font from there too. Use the same settings when you present the standalone camera, since it starts its own engine from them. If Bundle.main.url can’t find the bundle, it returns nil and the editor quietly falls back to the CDN, so check that the bundle is part of your app target.

Exporting the Finished Video

Once the user has finished with their creation, they tap the export button and the editor composes all of the video, audio, filters, and static items into a single .mp4 file. Then it displays a share sheet. If you’d rather do something different, like save the creation locally so you can play it back, you can override this default behavior.

The default behavior lives in the starter kit’s OnExport+Video.swift file, and the UI events documentation lists every event the editor understands. The onExport callback receives an engine, an eventHandler, and a third parameter for chaining handlers, which this example ignores. The engine is the underlying IMG.LY Engine object we’ve been using. The eventHandler lets us send information back to the editor UI about the progress, and it can close the editor when we’re done.

Here is an onExport callback that writes the video to the app’s documents directory instead of opening the share sheet:

VideoEditorConfiguration { builder in
  builder.onExport { engine, eventHandler, _ in
    guard let page = try engine.scene.getCurrentPage() else {
      throw EditorError("No page was found.")
    }
    eventHandler.send(.exportProgress(.relative(0)))

    let stream = try await engine.block.exportVideo(
      page,
      mimeType: .mp4,
      options: VideoExportOptions(videoBitrate: .auto)
    ) { _ in }

    for try await export in stream {
      try Task.checkCancellation()
      switch export {
      case let .progress(_, encodedFrames, totalFrames):
        eventHandler.send(.exportProgress(.relative(Float(encodedFrames) / Float(totalFrames))))
      case let .finished(video: videoData):
        let documentDirectory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
        let fileURL = documentDirectory
          .appendingPathComponent(UUID().uuidString)
          .appendingPathExtension("mp4")
        try videoData.write(to: fileURL, options: [.atomic])
        eventHandler.send(.exportCompleted { eventHandler.send(.closeEditor) })
        return
      }
    }
    try Task.checkCancellation()
    throw EditorError("Failed to export the content.")
  }
}

The code above starts the export with engine.block.exportVideo(_:mimeType:options:), which returns an async throwing stream. The items in the stream are either .progress or .finished. When a .progress item arrives, the callback sends an update to the eventHandler so the UI shows how far along the export is. When the .finished item arrives, it has an associated value that holds the data for the video. The callback writes that data to the documents directory with a UUID as the filename and an .mp4 extension, which is how the playback view finds the exported videos later. Finally, it shows the export completed message and closes the editor once the user dismisses it. If the export is canceled or the stream ends without a finished video, the callback stops with an error instead.

Setting Up a Playback View

For this example app, the playback view will play any clips in the app’s Documents directory. Each clip plays on a loop. The user can swipe up to get the next clip and tap to pause or resume the clip. If there are no clips, the user will be encouraged to make a new one.

screen shot of player

The playback logic lives in a small view model. When the video player view appears, the first task is to see if there are any videos to load. Any .mp4 files in the Documents directory are assumed to be ready to play.

import AVKit
import SwiftUI

@MainActor
final class FeedViewModel: ObservableObject {
  @Published private(set) var player: AVQueuePlayer?
  private var videos: [URL] = []
  private var currentIndex = 0
  private var looper: AVPlayerLooper?

  func loadVideos() {
    //get a handle to the documents directory for this app
    guard let documentDirectory = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { return }

    let files = (try? FileManager.default.contentsOfDirectory(at: documentDirectory, includingPropertiesForKeys: [.creationDateKey])) ?? []
    videos = files
      .filter { $0.pathExtension == "mp4" }
      .sorted { creationDate(of: $0) > creationDate(of: $1) }
    currentIndex = 0
    playCurrentVideo()
  }

  private func creationDate(of url: URL) -> Date {
    (try? url.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast
  }

  private func playCurrentVideo() {
    guard videos.indices.contains(currentIndex) else {
      player = nil
      looper = nil
      return
    }
    let queuePlayer = AVQueuePlayer()
    looper = AVPlayerLooper(player: queuePlayer, templateItem: AVPlayerItem(url: videos[currentIndex]))
    player = queuePlayer
    queuePlayer.play()
  }
}

The code above reads all of the filenames from the documents directory, keeps the .mp4 files in a videos array sorted newest first, and starts playing the first one. AVPlayerLooper takes care of the looping: it keeps queuing copies of the item on an AVQueuePlayer, so the clip starts over as soon as it reaches the end. Because player is a published property, the view updates whenever it changes, and when there are no videos it becomes nil.

In the TikTok app, you can advance to the next video by swiping up. Additionally, you can start and stop any video by tapping on the screen. We can add both of those features to the view model first.

func advanceVideo() {
  guard !videos.isEmpty else { return }
  currentIndex = (currentIndex + 1) % videos.count
  playCurrentVideo()
}

func pause() {
  player?.pause()
}

func togglePlayback() {
  guard let player else { return }
  if player.timeControlStatus == .playing {
    player.pause()
  } else {
    player.play()
  }
}

The advanceVideo function moves to the next file URL in the array of videos. When it reaches the end, it loops back to the file at index 0. Then it creates a new player, and because the view is observing the player property, it will update with the new video. The togglePlayback function checks whether the player is currently playing and pauses or resumes it. The pause function stops playback before the editor opens, so the feed doesn’t keep playing behind it.

Now the view can put it all together. We can detect the swipe with a DragGesture and evaluate the translation when it ends. VideoPlayer brings its own playback controls, so the tap and drag gestures go on a transparent overlay on top of the player. The .id modifier gives the player view a new identity for every clip, so SwiftUI rebuilds it whenever the view model swaps in a new player.

import AVKit
import IMGLYEditor
import IMGLYEngine
import SwiftUI

struct FeedView: View {
  @StateObject private var viewModel = FeedViewModel()
  @State private var isPresented = false

  let settings = EngineSettings(license: "<your license key>",
                                userID: "<your unique user id>")

  var body: some View {
    ZStack(alignment: .bottom) {
      if let player = viewModel.player {
        VideoPlayer(player: player)
          .id(ObjectIdentifier(player))
          .ignoresSafeArea()
          .overlay {
            Color.clear
              .contentShape(Rectangle())
              .onTapGesture { viewModel.togglePlayback() }
              .gesture(
                DragGesture(minimumDistance: 20)
                  .onEnded { value in
                    // Check for an upward swipe with minimal horizontal deviation
                    if value.translation.height < -50, abs(value.translation.width) < 100 {
                      viewModel.advanceVideo()
                    }
                  }
              )
          }
      } else {
        Text("Looks like you haven't made any videos yet. Use the button at the bottom of the screen to make a few!")
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      }

      Button {
        viewModel.pause()
        isPresented = true
      } label: {
        Image(systemName: "plus.circle.fill")
          .resizable()
          .frame(width: 56, height: 56)
      }
      .padding(.bottom, 32)
    }
    .onAppear { viewModel.loadVideos() }
    .fullScreenCover(isPresented: $isPresented, onDismiss: viewModel.loadVideos) {
      NavigationStack {
        Editor(settings)
          .imgly.configuration {
            VideoEditorConfiguration { builder in
              //add the onExport callback from the previous section here
            }
          }
      }
    }
  }
}

Each time the app is launched, the view gets the onAppear message and loads the videos. When the editor is dismissed, the onDismiss closure of fullScreenCover loads them again, so a video the user just exported is the first one in the feed.

With this view, the user can create new videos by tapping the creation button and swipe to view all of their created videos.

Where to Go From Here

This tutorial has focused on how the CreativeEditor SDK can help you quickly make a video creation app like TikTok. Good next steps would be to further customize the editing tools and build out the network for sharing and tagging videos. Something that is important to consider is the data structure for each video. The iOS system is optimized to read only as much of a video file off of disk as is needed at any moment. Your app should use those optimizations to run faster. So, don’t load the whole video into memory as a Data object. Your data structures should keep the large video files somehow separate from the much smaller metadata (likes, comments, etc.). Consider storing the URL or filename of the video in the same object as the likes and comments. This will also allow you to cache video files after they have been downloaded so that you don’t need to redownload them when other data such as comments or number of likes changes.

Frequently Asked Questions

Do I need a license to try the iOS editor? No. If you pass nil as the license in EngineSettings, the editor runs in evaluation mode and every exported video carries a watermark. A trial or production license removes the watermark.

Which iOS versions does the video editor support? The editor and the camera require iOS 16 or later, and SDK 1.82.0 needs Xcode with Swift 6.3.1 or later to resolve. The starter kit presents the editor inside a NavigationStack, which is available from iOS 16 as well.

Can I use my own camera code instead of the IMG.LY camera? Yes. Record the clip with your own camera, then create the scene from its URL with engine.scene.create(fromVideo:) in the editor’s onCreate callback. To use Apple’s camera inside the editor instead, replace the camera button in the starter kit’s dock with Dock.Buttons.systemCamera(action: { $0.eventHandler.send(.addFromSystemCamera(addToBackgroundTrack: true)) }) so the clips land on the main video track.

Where does the editor save exported videos? By default, the starter kit exports an MP4 to a temporary file and opens the share sheet. To keep the video in your app, override the onExport callback and write the exported data to the documents directory, as shown in this tutorial.

Can I use the editor in a UIKit app? Yes. The editor is a SwiftUI view, so you can wrap it in a UIHostingController and present that from your view controllers. Keep it inside a navigation container rather than at the root of the view hierarchy, because the editor places its buttons in the navigation bar.

Can users add music and captions to their videos? Yes. The starter kit’s dock includes an audio library, voiceover recording and captions, and the timeline shows audio clips and captions on their own tracks alongside the video.

Thanks for reading! We hope that you’ve gotten a better understanding for how a tool like the CreativeEditor SDK can bring your ideas to market faster. Feel free to reach out to us with any questions, comments, or suggestions.

Looking for more video capabilities? Check out our solutions for Short Video Creation, and the Mobile Video SDK!

To stay in the loop, subscribe to our Newsletter or follow us on LinkedIn and X.