📷 Camera App

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:

  1. TeleprompterOverlay - Componente UI principal (SwiftUI)
  2. TeleprompterViewModel - Lógica de scroll e interações
  3. 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:


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:


📱 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.  roteiro enquanto grava
   
7. Para gravação  scroll para
   
8. Repete para múltiplos takes

🧵 Performance

Otimizações

  1. 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)
    }
    
  2. Cache de Cálculos: Evita recalcular mesma signature
    let signature = "\(text.hashValue)|\(fontSize)|\(Int(width))"
    guard signature != lastContentSignature else { return }
    
  3. Animações Condicionais: Desabilita durante interação
    .animation(viewModel.isInteracting ? .none : .default, value: viewModel.overlayOffset)
    
  4. 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:

Height incorreto

Sintomas: Texto cortado ou muito espaço vazio.

Solução:

Loop infinito de scroll

Sintomas: contentOffset fica atualizando infinitamente.

Solução:


📚 Ver Também


← SegmentedRecorder Componentes →