SwiftUI文本覆盖层与导出视频显示不一致问题排查
我在竖屏、固定尺寸的SwiftUI视图(覆盖于比例可变的视频视图之上)中,实现了可拖拽缩放的Text组件。SwiftUI视图代码如下:
struct EditPostLyricsTextView: View { @EnvironmentObject private var viewModel: EditPostView.ViewModel var body: some View { TitleView( text: viewModel.currentLyricsText, font: UIFont(name: viewModel.lyricsMetadata.fontName, size: 17)!, fontSize: 25 * viewModel.lyricsMetadata.fontScale, alignment: viewModel.lyricsMetadata.alignment, textColor: viewModel.lyricsMetadata.color ) .padding(50) .frame(maxWidth: .infinity) .padding(.horizontal, 20) .overlay { Color.responsiveClear } .position(viewModel.lyricsMetadata.coordinates) .gesture( MagnificationGesture() .onChanged { value in let delta = value / viewModel.lyricsMetadata.fontScale let newScale = viewModel.lyricsMetadata.fontScale * delta viewModel.lyricsMetadata.fontScale = min(2, max(1, newScale)) }.simultaneously(with: DragGesture() .onChanged { value in viewModel.lyricsMetadata.coordinates = value.location }).simultaneously(with: TapGesture() .onEnded(viewModel.presentEditLyricsView)) ) .clipped() } }
我需要导出带有该文本覆盖层的视频,确保在所有视频分辨率下文本的位置和大小都与SwiftUI视图一致。视频编辑相关代码如下:
struct TextSegment { let text: String let startTime: Double let endTime: Double let originalCanvasSize: CGSize let padding: CGFloat var coordinates: CGPoint let color: UIColor let fontName: String let fontSize: CGFloat let alignment: NSTextAlignment } struct VideoEditorService { func addTextOverlaysToVideo(videoURL: URL, textSegments: [TextSegment], completion: @escaping (URL?) -> Void) { let videoAsset = AVURLAsset(url: videoURL) let composition = AVMutableComposition() let timeRange = CMTimeRange(start: .zero, duration: videoAsset.duration) guard let videoTrack = videoAsset.tracks(withMediaType: .video).first, let compositionTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { completion(nil) return } do { if let audioAssetTrack = videoAsset.tracks(withMediaType: .audio).first, let compositionAudioTrack = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { try compositionAudioTrack.insertTimeRange( timeRange, of: audioAssetTrack, at: .zero) } try compositionTrack.insertTimeRange(timeRange, of: videoTrack, at: .zero) let videoComposition = AVMutableVideoComposition(propertiesOf: videoAsset) let overlayLayer = CALayer() overlayLayer.frame = CGRect(origin: .zero, size: videoComposition.renderSize) textSegments.forEach { segment in addText( textSegment: segment, to: overlayLayer, videoSize: videoComposition.renderSize ) } // Apply the overlay layer with animations let parentLayer = CALayer() parentLayer.frame = CGRect(x: 0, y: 0, width: videoComposition.renderSize.width, height: videoComposition.renderSize.height) parentLayer.addSublayer(overlayLayer) videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: overlayLayer, in: parentLayer) // Export the video exportVideo( originalVideoURL: videoURL, composition: composition, videoComposition: videoComposition, completion: completion ) } catch { print("Failed to add text overlay: \(error)") completion(nil) } } private func addText(textSegment: TextSegment, to layer: CALayer, videoSize: CGSize) { let originalWidth = textSegment.originalCanvasSize.width - textSegment.padding let maxWidth = (originalWidth * videoSize.width) / textSegment.originalCanvasSize.width let fontSize = min((videoSize.width * textSegment.fontSize) / textSegment.originalCanvasSize.width, (videoSize.height * textSegment.fontSize) / textSegment.originalCanvasSize.height) let standardRatio = (textSegment.originalCanvasSize.width / textSegment.fontSize) * (textSegment.originalCanvasSize.height / textSegment.fontSize) let attributedText = NSAttributedString( string: textSegment.text, attributes: [ .font: UIFont(name: textSegment.fontName, size: fontSize) ?? .defaultHeavy(size: fontSize), .foregroundColor: textSegment.color, ] ) let textLayer = CATextLayer() textLayer.string = attributedText // textLayer.backgroundColor = UIColor.red.cgColor textLayer.alignmentMode = textSegment.alignment == .center ? .center : textSegment.alignment == .left ? .left : .right textLayer.isWrapped = true textLayer.truncationMode = .end textLayer.contentsScale = UIScreen.main.scale textLayer.shouldRasterize = true textLayer.rasterizationScale = UIScreen.main.scale textLayer.opacity = 0 let textSize = attributedText.boundingRect(with: CGSize(width: maxWidth, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).size let height = ceil(textSize.height) print("maxWidth", maxWidth) print("textSize", ceil(textSize.height)) print("textSize", textSegment.coordinates) print("textSize", videoSize) let normalisedX = ((videoSize.width * textSegment.coordinates.x) / textSegment.originalCanvasSize.width) - (maxWidth / 2) let normalisedY = (videoSize.height - (videoSize.height * textSegment.coordinates.y) / textSegment.originalCanvasSize.height) - (height / 2) textLayer.frame = CGRect( x: normalisedX, y: normalisedY, width: maxWidth, height: height ) textLayer.displayIfNeeded() let startVisible = CABasicAnimation(keyPath: "opacity") startVisible.fromValue = 0.0 startVisible.toValue = 1.0 startVisible.duration = 0.05 // for appearing in duration startVisible.beginTime = AVCoreAnimationBeginTimeAtZero + textSegment.startTime startVisible.isRemovedOnCompletion = false startVisible.fillMode = CAMediaTimingFillMode.forwards textLayer.add(startVisible, forKey: "startAnimation") let endVisible = CABasicAnimation(keyPath: "opacity") endVisible.fromValue = 1.0 endVisible.toValue = 0.0 endVisible.duration = 0.05 // for disappearing in duration endVisible.beginTime = AVCoreAnimationBeginTimeAtZero + textSegment.endTime endVisible.fillMode = CAMediaTimingFillMode.forwards endVisible.isRemovedOnCompletion = false textLayer.add(endVisible, forKey: "endAnimation") layer.addSublayer(textLayer) } }
我已对字体大小、坐标等做了比例适配,部分场景下导出视频的文本显示与SwiftUI视图一致,但有时会出现文本多出一行的情况,请问我哪里处理有误?
核心问题点
1. 文本可用宽度计算不匹配
SwiftUI视图中,TitleView先后设置了.padding(50)和.padding(.horizontal, 20),实际文本的可用宽度是原始画布宽度减去左右各50+20的内边距,但导出代码中仅用originalCanvasSize.width - textSegment.padding计算原始宽度,导致导出时文本可用宽度比SwiftUI中更宽,触发额外换行。
2. 文本排版逻辑不一致
SwiftUI的Text和CATextLayer的渲染规则存在差异:
- 两者默认行高、字符间距不同;
CATextLayer的isWrapped换行规则和SwiftUI自动换行逻辑不完全对齐;- 用
boundingRect计算文本尺寸时,未同步CATextLayer的换行和截断规则。
3. 坐标与尺寸的像素对齐问题
boundingRect返回的尺寸是浮点值,仅对高度做ceil处理但宽度存在精度误差,导致CATextLayer实际可容纳宽度和计算值有偏差,触发额外换行。同时Y轴坐标转换逻辑错误,可能将文本推到边缘间接影响换行。
修复步骤
1. 统一文本可用宽度计算
修改导出代码中originalWidth的计算,完全还原SwiftUI中的内边距:
// 替换原originalWidth计算 let totalHorizontalPadding = 50*2 + 20*2 // 对应SwiftUI的padding(50) + padding(.horizontal,20) let originalWidth = textSegment.originalCanvasSize.width - totalHorizontalPadding let maxWidth = (originalWidth * videoSize.width) / textSegment.originalCanvasSize.width
如果TextSegment的padding参数用来传递总内边距,需在生成TextSegment时传入正确的总内边距值。
2. 对齐文本排版规则
给NSAttributedString添加段落样式,匹配SwiftUI的行高和换行规则:
// 创建段落样式,对齐SwiftUI的排版 let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = textSegment.alignment paragraphStyle.lineBreakMode = .byWordWrapping // 和SwiftUI自动换行逻辑一致 paragraphStyle.lineHeightMultiple = 1.2 // 根据SwiftUI默认行高调整,通常为1.1~1.3 // 更新attributedText的属性 let attributedText = NSAttributedString( string: textSegment.text, attributes: [ .font: UIFont(name: textSegment.fontName, size: fontSize) ?? .defaultHeavy(size: fontSize), .foregroundColor: textSegment.color, .paragraphStyle: paragraphStyle ] )
同时确保CATextLayer的换行模式和段落样式一致:
textLayer.truncationMode = .none // 不需要截断时和SwiftUI保持一致;需要截断则保留.end
3. 修正文本尺寸计算与像素对齐
计算textSize时使用和CATextLayer完全一致的参数,对宽度也做像素对齐:
let textSize = attributedText.boundingRect( with: CGSize(width: maxWidth, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading, .usesDeviceMetrics], // 提升计算精度 context: nil ).size let width = ceil(textSize.width) let height = ceil(textSize.height) // 更新textLayer的frame textLayer.frame = CGRect( x: normalisedX, y: normalisedY, width: width, // 用对齐后的宽度 height: height )
4. 修正坐标转换逻辑
SwiftUI和CALayer的Y轴都是向下递增的,不需要反转Y轴,正确的中心点转换:
// 替换原normalisedY计算 let normalisedX = (videoSize.width * textSegment.coordinates.x / textSegment.originalCanvasSize.width) - (width / 2) let normalisedY = (videoSize.height * textSegment.coordinates.y / textSegment.originalCanvasSize.height) - (height / 2)
内容的提问来源于stack exchange,提问作者Sotiris Kaniras

