Sistema de Teleprompter
Sistema completo de teleprompter com overlay flutuante, scroll automático e controles de interação.
Arquivos: TeleprompterOverlay.swift
, TeleprompterViewModel.swift
, TeleprompterTextView.swift
📋 Visão Geral
O sistema de teleprompter é composto por três componentes:
- TeleprompterOverlay - Componente UI principal (SwiftUI)
- TeleprompterViewModel - Lógica de scroll e interações
- TeleprompterTextView - Bridge UIKit para UITextView
🏗️ Arquitetura
TeleprompterOverlay (SwiftUI)
├── TeleprompterTextView (UIViewRepresentable → UITextView)
│ ├── Coordinator (UITextViewDelegate)
│ └── Scroll programático via contentOffset binding
├── TeleprompterViewModel (@ObservableObject)
│ ├── Timer para scroll automático (60fps)
│ ├── Cálculo de content height
│ └── Gestão de interações (drag, resize)
├── PlayPauseButton
├── ResizeHandle (bottom-right)
├── MoveHandle (bottom-left)
└── BottomSlidersBar
├── Font size slider
└── Speed slider
📦 TeleprompterViewModel
ViewModel que gerencia todo o estado e lógica do teleprompter.
Propriedades Publicadas
@Published var contentOffset: CGFloat = 0 // Posição de scroll atual
@Published var contentHeight: CGFloat = 0 // Altura total do conteúdo
@Published var isPlaying: Bool = false // Estado de reprodução
@Published var overlaySize: CGSize // Tamanho do overlay
@Published var overlayOffset: CGSize // Posição na tela
@Published var isInteracting: Bool = false // Durante drag/resize
@Published var isEditorPresented: Bool = false // Editor fullscreen
Scroll Automático
func startScrolling(speed: Double, viewportHeight: CGFloat)
Descrição: Inicia scroll automático com Timer de 60fps.
Algoritmo:
scrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
let now = Date()
let deltaTime = now.timeIntervalSince(self.lastTickTime)
self.lastTickTime = now
// Incrementa offset baseado na velocidade
self.contentOffset += speed * deltaTime
// Verifica fim
let maxOffset = max(0, self.contentHeight - viewportHeight)
if self.contentOffset >= maxOffset {
if self.pauseAtEnd {
self.contentOffset = maxOffset
self.stopScrolling()
} else {
self.contentOffset = 0 // Loop
}
}
}
Parâmetros:
speed
: Velocidade em pontos por segundo (8-60 pts/s)viewportHeight
: Altura visível do teleprompter
func stopScrolling()
Descrição: Para o scroll automático, mantém offset atual.
func pauseScrolling()
Descrição: Pausa e define isPlaying = false
.
func resumeScrolling(speed: Double, viewportHeight: CGFloat)
Descrição: Retoma scroll do offset atual.
Cálculo de Content Height
func updateContentHeight(text: String, fontSize: CGFloat, width: CGFloat)
Descrição: Calcula altura total do texto com tipografia exata.
Implementação:
private func calculateContentHeight(text: String, fontSize: CGFloat, width: CGFloat) -> CGFloat {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: fontSize, weight: .semibold),
.paragraphStyle: paragraphStyle
]
let targetWidth = width - TeleprompterConfig.contentPadding
let boundingRect = (text as NSString).boundingRect(
with: CGSize(width: targetWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: attributes,
context: nil
)
return ceil(boundingRect.height + verticalPadding)
}
Cache: Evita recalcular para mesmos parâmetros.
let signature = "\(text.hashValue)|\(fontSize)|\(Int(width))"
guard signature != lastContentSignature else { return }
Interações
func updateOverlayPosition(translation: CGSize)
Descrição: Atualiza posição durante drag.
Uso: Chamado no .onChanged
do DragGesture
.
func finalizeOverlayPosition(parentSize: CGSize)
Descrição: Aplica constraints após drag (clamp to screen bounds).
Implementação:
let margin: CGFloat = 24
overlayOffset.width = max(-(parentSize.width - margin),
min(parentSize.width - margin, overlayOffset.width))
overlayOffset.height = max(-(parentSize.height - margin),
min(parentSize.height - margin, overlayOffset.height))
Uso: Chamado no .onEnded
do DragGesture
.
func resizeOverlay(translation: CGSize, parentSize: CGSize)
Descrição: Redimensiona overlay durante drag diagonal.
Constraints:
let minWidth = TeleprompterConfig.minOverlayWidth // 280
let minHeight = TeleprompterConfig.minOverlayHeight // 180
newWidth = max(minWidth, min(parentSize.width, newWidth))
newHeight = max(minHeight, min(parentSize.height, newHeight))
func handleRecordingStateChange(isRecording: Bool, speed: Double, viewportHeight: CGFloat)
Descrição: Sincroniza com estado de gravação.
Comportamento:
- Se
isRecording = true
: Inicia scroll automático - Se
isRecording = false
: Para scroll, mantém offset
📱 TeleprompterTextView
Bridge UIKit para controle preciso de scroll.
Estrutura
struct TeleprompterTextView: UIViewRepresentable {
@Binding var text: String
@Binding var contentOffset: CGFloat
let fontSize: CGFloat
let userInteractionEnabled: Bool
func makeUIView(context: Context) -> UITextView { /* ... */ }
func updateUIView(_ uiView: UITextView, context: Context) { /* ... */ }
func makeCoordinator() -> Coordinator { /* ... */ }
}
Coordinator
class Coordinator: NSObject, UITextViewDelegate {
var isProgrammaticScroll: Bool = false
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !isProgrammaticScroll else { return }
// Scroll manual do usuário
let y = max(0, scrollView.contentOffset.y)
contentOffset.wrappedValue = y
}
}
Propósito: Evita loop infinito (SwiftUI → UIKit → SwiftUI).
Sincronização Bidirecional
SwiftUI → UIKit (scroll programático):
func updateUIView(_ tv: UITextView, context: Context) {
let currentY = tv.contentOffset.y
if abs(currentY - contentOffset) > 0.5 {
context.coordinator.isProgrammaticScroll = true
tv.setContentOffset(CGPoint(x: 0, y: contentOffset), animated: false)
DispatchQueue.main.async {
context.coordinator.isProgrammaticScroll = false
}
}
}
UIKit → SwiftUI (scroll manual):
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !isProgrammaticScroll else { return }
contentOffset.wrappedValue = max(0, scrollView.contentOffset.y)
}
🎨 TeleprompterOverlay
Componente visual principal.
Layout
ZStack {
// Background com material
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(.white.opacity(0.2), lineWidth: 1)
)
VStack {
// Viewport de scroll
TeleprompterTextView(...)
.overlay(alignment: .bottom) {
LinearGradient(/* fade effect */)
}
// Controles
BottomSlidersBar(...)
}
// Handles
PlayPauseButton()
MoveHandle()
ResizeHandle()
}
.frame(width: viewModel.overlaySize.width, height: viewModel.overlaySize.height)
.offset(viewModel.overlayOffset)
Gestos
Tap no Overlay: Abre editor fullscreen
.onTapGesture {
viewModel.isEditorPresented = true
}
Drag no MoveHandle: Move overlay
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
viewModel.updateOverlayPosition(translation: value.translation)
}
.onEnded { _ in
viewModel.finalizeOverlayPosition(parentSize: parentSize)
}
)
Drag no ResizeHandle: Redimensiona
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
viewModel.resizeOverlay(translation: value.translation, parentSize: parentSize)
}
.onEnded { _ in
viewModel.finalizeResize()
}
)
⚙️ Configuração
struct TeleprompterConfig {
static let minFontSize: CGFloat = 18
static let maxFontSize: CGFloat = 36
static let minSpeed: Double = 8 // pts/segundo
static let maxSpeed: Double = 60
static let scrollFrameRate: Double = 60.0
static let viewportPadding: CGFloat = 56 // Normal
static let compactViewportPadding: CGFloat = 36 // Durante gravação
static let minOverlayWidth: CGFloat = 280
static let minOverlayHeight: CGFloat = 180
}
🎯 Casos de Uso
Ativar Teleprompter
// No ViewModel
isTeleprompterOn = true
// Overlay aparece na tela
Editar Roteiro
// Usuário toca no overlay
// Editor fullscreen abre
TeleprompterEditorSheet(
text: $teleprompterText,
fontSize: $teleprompterFontSize
)
Iniciar Gravação com Scroll
// Usuário aperta botão de gravar
model.toggleRecording()
// TeleprompterOverlay detecta mudança
.onChange(of: isRecording) { newValue in
viewModel.handleRecordingStateChange(
isRecording: newValue,
speed: speed,
viewportHeight: viewportHeight
)
}
// Scroll automático inicia
🎬 Workflow Completo
1. Usuário ativa teleprompter
↓
2. Escreve roteiro no editor fullscreen
↓
3. Ajusta font size e velocidade com sliders
↓
4. Posiciona e redimensiona overlay
↓
5. Inicia gravação → scroll automático começa
↓
6. Lê roteiro enquanto grava
↓
7. Para gravação → scroll para
↓
8. Repete para múltiplos takes
🧵 Performance
Otimizações
- Debouncing: Recalcula height apenas após interação terminar
if isInteracting { scheduledUpdate?.cancel() let work = DispatchWorkItem { updateContentHeight(...) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work) }
- Cache de Cálculos: Evita recalcular mesma signature
let signature = "\(text.hashValue)|\(fontSize)|\(Int(width))" guard signature != lastContentSignature else { return }
- Animações Condicionais: Desabilita durante interação
.animation(viewModel.isInteracting ? .none : .default, value: viewModel.overlayOffset)
- Timer de 60fps: Smooth scroll consistente
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true)
🐛 Troubleshooting
Scroll trava ou pula
Sintomas: Scroll não suave, pula frames.
Solução:
- Verificar se Timer é 60fps
- Verificar debouncing durante resize
- Verificar cálculos pesados na main thread
Height incorreto
Sintomas: Texto cortado ou muito espaço vazio.
Solução:
- Garantir tipografia idêntica entre cálculo e UITextView
- Verificar padding vertical
- Forçar recálculo após mudança de width
Loop infinito de scroll
Sintomas: contentOffset fica atualizando infinitamente.
Solução:
- Usar flag
isProgrammaticScroll
no Coordinator - Garantir threshold de 0.5 pontos no
updateUIView
📚 Ver Também
- CameraViewModel - Coordenação principal
- Guia de Teleprompter - Tutorial de uso
- Performance - Otimizações aplicadas
← SegmentedRecorder | Componentes → |