处理聊天数据打印机效果
This commit is contained in:
parent
600f47c18d
commit
bd8703656f
|
|
@ -78,6 +78,12 @@ class SessionController: CLBaseViewController {
|
||||||
var isStreamChatMode = false
|
var isStreamChatMode = false
|
||||||
var streamMessages: [StreamChatMessageModel] = []
|
var streamMessages: [StreamChatMessageModel] = []
|
||||||
private var currentStreamingMessageId: String?
|
private var currentStreamingMessageId: String?
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
private var typingTargetText: String = ""
|
||||||
|
private var typingDisplayedLength: Int = 0
|
||||||
|
private var typingCharactersPerSecond: CGFloat = 20
|
||||||
|
private var lastDisplayLinkTimestamp: CFTimeInterval = 0
|
||||||
|
private var shouldFinishTypingAfterTarget = false
|
||||||
|
|
||||||
convenience init(accountID: String) {
|
convenience init(accountID: String) {
|
||||||
self.init()
|
self.init()
|
||||||
|
|
@ -598,21 +604,43 @@ extension SessionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appendOrUpdateIncomingChunk(_ chunk: String) {
|
private func appendOrUpdateIncomingChunk(_ chunk: String) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, chunk.isEmpty == false else { return }
|
||||||
|
|
||||||
|
var messageIndex: Int
|
||||||
|
var didInsertRow = false
|
||||||
|
|
||||||
if let currentId = self.currentStreamingMessageId,
|
if let currentId = self.currentStreamingMessageId,
|
||||||
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
|
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) {
|
||||||
self.streamMessages[index].text += chunk
|
self.typingTargetText = self.mergeText(current: self.typingTargetText, incoming: chunk)
|
||||||
|
messageIndex = index
|
||||||
} else {
|
} else {
|
||||||
let message = StreamChatMessageModel(id: UUID().uuidString,
|
let message = StreamChatMessageModel(id: UUID().uuidString,
|
||||||
text: chunk,
|
text: "",
|
||||||
isSelf: false,
|
isSelf: false,
|
||||||
isStreaming: true)
|
isStreaming: true)
|
||||||
self.streamMessages.append(message)
|
self.streamMessages.append(message)
|
||||||
self.currentStreamingMessageId = message.id
|
self.currentStreamingMessageId = message.id
|
||||||
|
self.typingTargetText = chunk
|
||||||
|
self.typingDisplayedLength = 0
|
||||||
|
self.shouldFinishTypingAfterTarget = false
|
||||||
|
messageIndex = self.streamMessages.count - 1
|
||||||
|
didInsertRow = true
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tableView?.reloadData()
|
guard let tableView = self.tableView else { return }
|
||||||
self.scrollToBottom(self.tableView, animated: true)
|
let indexPath = IndexPath(row: messageIndex, section: 1)
|
||||||
|
|
||||||
|
if didInsertRow {
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView.beginUpdates()
|
||||||
|
tableView.insertRows(at: [indexPath], with: .none)
|
||||||
|
tableView.endUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.startDisplayLinkIfNeeded()
|
||||||
|
self.smoothScrollToLatest()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,21 +650,37 @@ extension SessionController {
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
isStreaming: false)
|
isStreaming: false)
|
||||||
streamMessages.append(message)
|
streamMessages.append(message)
|
||||||
tableView?.reloadData()
|
guard let tableView = tableView else { return }
|
||||||
scrollToBottom(tableView, animated: true)
|
let indexPath = IndexPath(row: streamMessages.count - 1, section: 1)
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView.beginUpdates()
|
||||||
|
tableView.insertRows(at: [indexPath], with: .none)
|
||||||
|
tableView.endUpdates()
|
||||||
|
}
|
||||||
|
smoothScrollToLatest()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finalizeStreamingMessage() {
|
private func finalizeStreamingMessage() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
guard let currentId = self.currentStreamingMessageId,
|
guard let currentId = self.currentStreamingMessageId,
|
||||||
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) else {
|
let index = self.streamMessages.firstIndex(where: { $0.id == currentId }) else {
|
||||||
IMSSEManager.shared.disconnect()
|
IMSSEManager.shared.disconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.streamMessages[index].isStreaming = false
|
self.streamMessages[index].isStreaming = false
|
||||||
self.currentStreamingMessageId = nil
|
self.shouldFinishTypingAfterTarget = true
|
||||||
self.tableView?.reloadData()
|
|
||||||
self.scrollToBottom(self.tableView, animated: true)
|
if self.typingTargetText.isEmpty {
|
||||||
|
self.typingTargetText = self.streamMessages[index].text
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.typingDisplayedLength >= self.typingTargetText.count {
|
||||||
|
self.finishTypingSequence()
|
||||||
|
} else {
|
||||||
|
self.startDisplayLinkIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
IMSSEManager.shared.disconnect()
|
IMSSEManager.shared.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,28 +688,31 @@ extension SessionController {
|
||||||
private func parseStreamChunk(_ payload: Any) -> StreamChunkResult {
|
private func parseStreamChunk(_ payload: Any) -> StreamChunkResult {
|
||||||
if let dict = payload as? [String: Any] {
|
if let dict = payload as? [String: Any] {
|
||||||
let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false
|
let finished = (dict["finished"] as? Bool) ?? (dict["done"] as? Bool) ?? false
|
||||||
let text = (dict["message"] as? String)
|
if let text = extractText(from: dict) {
|
||||||
?? (dict["content"] as? String)
|
|
||||||
?? (dict["data"] as? String)
|
|
||||||
return StreamChunkResult(text: text, finished: finished)
|
return StreamChunkResult(text: text, finished: finished)
|
||||||
|
}
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: dict, options: []),
|
||||||
|
let jsonString = String(data: data, encoding: .utf8) {
|
||||||
|
return StreamChunkResult(text: jsonString, finished: finished)
|
||||||
|
}
|
||||||
|
return StreamChunkResult(text: nil, finished: finished)
|
||||||
} else if let str = payload as? String {
|
} else if let str = payload as? String {
|
||||||
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if normalized.isEmpty {
|
||||||
return StreamChunkResult(text: nil, finished: false)
|
return StreamChunkResult(text: nil, finished: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let data = trimmed.data(using: .utf8),
|
if let data = normalized.data(using: .utf8),
|
||||||
let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []),
|
let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []) {
|
||||||
let dict = jsonObj as? [String: Any] {
|
return parseStreamChunk(jsonObj)
|
||||||
return parseStreamChunk(dict)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed.lowercased() == "[done]" {
|
if normalized.lowercased() == "[done]" {
|
||||||
return StreamChunkResult(text: nil, finished: true)
|
return StreamChunkResult(text: nil, finished: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let containsDoneFlag = trimmed.lowercased().contains("__end__") || trimmed.lowercased().contains("<end>")
|
let containsDoneFlag = normalized.lowercased().contains("__end__") || normalized.lowercased().contains("<end>")
|
||||||
return StreamChunkResult(text: trimmed, finished: containsDoneFlag)
|
return StreamChunkResult(text: str, finished: containsDoneFlag)
|
||||||
}
|
}
|
||||||
return StreamChunkResult(text: nil, finished: false)
|
return StreamChunkResult(text: nil, finished: false)
|
||||||
}
|
}
|
||||||
|
|
@ -675,6 +722,193 @@ extension SessionController {
|
||||||
let index = streamMessages.firstIndex(where: { $0.id == currentId }) {
|
let index = streamMessages.firstIndex(where: { $0.id == currentId }) {
|
||||||
streamMessages[index].isStreaming = false
|
streamMessages[index].isStreaming = false
|
||||||
}
|
}
|
||||||
|
cleanupTypingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smoothScrollToLatest() {
|
||||||
|
guard let tableView = tableView else { return }
|
||||||
|
let contentHeight = tableView.contentSize.height
|
||||||
|
let tableViewHeight = tableView.bounds.height
|
||||||
|
let offsetY = tableView.contentOffset.y
|
||||||
|
let distanceFromBottom = contentHeight - offsetY - tableViewHeight
|
||||||
|
let shouldAutoScroll = distanceFromBottom <= 200 || contentHeight <= tableViewHeight
|
||||||
|
guard shouldAutoScroll else { return }
|
||||||
|
let lastIndex = streamMessages.count - 1
|
||||||
|
guard lastIndex >= 0 else { return }
|
||||||
|
let indexPath = IndexPath(row: lastIndex, section: 1)
|
||||||
|
tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startDisplayLinkIfNeeded() {
|
||||||
|
guard displayLink == nil else { return }
|
||||||
|
lastDisplayLinkTimestamp = 0
|
||||||
|
let link = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
displayLink = link
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopDisplayLink() {
|
||||||
|
displayLink?.invalidate()
|
||||||
|
displayLink = nil
|
||||||
|
lastDisplayLinkTimestamp = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDisplayLink(_ link: CADisplayLink) {
|
||||||
|
guard let currentId = currentStreamingMessageId,
|
||||||
|
let index = streamMessages.firstIndex(where: { $0.id == currentId }),
|
||||||
|
typingTargetText.isEmpty == false else {
|
||||||
|
stopDisplayLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let delta: CFTimeInterval
|
||||||
|
if lastDisplayLinkTimestamp == 0 {
|
||||||
|
delta = link.duration
|
||||||
|
} else {
|
||||||
|
delta = link.timestamp - lastDisplayLinkTimestamp
|
||||||
|
}
|
||||||
|
lastDisplayLinkTimestamp = link.timestamp
|
||||||
|
let rawCharacters = typingCharactersPerSecond * CGFloat(delta)
|
||||||
|
let charactersThisFrame = max(1, Int(floor(rawCharacters)))
|
||||||
|
guard charactersThisFrame > 0 else { return }
|
||||||
|
|
||||||
|
let newLength = min(typingTargetText.count, typingDisplayedLength + charactersThisFrame)
|
||||||
|
guard newLength > typingDisplayedLength else { return }
|
||||||
|
|
||||||
|
typingDisplayedLength = newLength
|
||||||
|
let newText = String(typingTargetText.prefix(newLength))
|
||||||
|
streamMessages[index].text = newText
|
||||||
|
|
||||||
|
if let tableView = tableView {
|
||||||
|
let indexPath = IndexPath(row: index, section: 1)
|
||||||
|
if let cell = tableView.cellForRow(at: indexPath) as? StreamChatBubbleCell {
|
||||||
|
cell.updateText(newText)
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView.beginUpdates()
|
||||||
|
tableView.endUpdates()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smoothScrollToLatest()
|
||||||
|
}
|
||||||
|
|
||||||
|
if typingDisplayedLength == typingTargetText.count {
|
||||||
|
if shouldFinishTypingAfterTarget || streamMessages[index].isStreaming == false {
|
||||||
|
finishTypingSequence()
|
||||||
|
} else {
|
||||||
|
stopDisplayLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishTypingSequence() {
|
||||||
|
guard let currentId = currentStreamingMessageId,
|
||||||
|
let index = streamMessages.firstIndex(where: { $0.id == currentId }) else {
|
||||||
|
cleanupTypingState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamMessages[index].text = typingTargetText
|
||||||
|
|
||||||
|
if let tableView = tableView {
|
||||||
|
let indexPath = IndexPath(row: index, section: 1)
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView.beginUpdates()
|
||||||
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||||||
|
tableView.endUpdates()
|
||||||
|
}
|
||||||
|
smoothScrollToLatest()
|
||||||
|
}
|
||||||
|
cleanupTypingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupTypingState() {
|
||||||
|
stopDisplayLink()
|
||||||
|
typingTargetText = ""
|
||||||
|
typingDisplayedLength = 0
|
||||||
|
shouldFinishTypingAfterTarget = false
|
||||||
currentStreamingMessageId = nil
|
currentStreamingMessageId = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将 SSE 片段安全拼接,尽量避免重复,但绝不丢字
|
||||||
|
private func mergeText(current: String, incoming: String) -> String {
|
||||||
|
if current.isEmpty { return incoming }
|
||||||
|
if incoming.isEmpty { return current }
|
||||||
|
|
||||||
|
// 后端每次返回完整内容:直接用较长的那个
|
||||||
|
if incoming.hasPrefix(current) { return incoming }
|
||||||
|
if current.hasPrefix(incoming) { return current }
|
||||||
|
|
||||||
|
// 完全重复或包含关系,直接保留已有内容
|
||||||
|
if current.contains(incoming) { return current }
|
||||||
|
if incoming.contains(current) { return incoming }
|
||||||
|
|
||||||
|
// 其他情况:不做重叠裁剪,直接拼接,保证不丢任意字符
|
||||||
|
return current + incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractText(from dictionary: [String: Any]) -> String? {
|
||||||
|
let candidateKeys = ["message", "content", "data", "text", "value", "delta"]
|
||||||
|
for key in candidateKeys {
|
||||||
|
if let value = dictionary[key] as? String, value.isEmpty == false {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nestedData = dictionary["data"] as? [String: Any],
|
||||||
|
let text = extractText(from: nestedData) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nestedDelta = dictionary["delta"] as? [String: Any],
|
||||||
|
let text = extractText(from: nestedDelta) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resultDict = dictionary["result"] as? [String: Any],
|
||||||
|
let text = extractText(from: resultDict) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if let choices = dictionary["choices"] as? [[String: Any]] {
|
||||||
|
for choice in choices {
|
||||||
|
if let text = extractText(from: choice) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if let delta = choice["delta"] as? [String: Any],
|
||||||
|
let text = extractText(from: delta) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let segments = dictionary["segments"] as? [[String: Any]] {
|
||||||
|
var combined = ""
|
||||||
|
for segment in segments {
|
||||||
|
if let text = extractText(from: segment) {
|
||||||
|
combined += text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if combined.isEmpty == false {
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let contentArray = dictionary["content"] as? [[String: Any]] {
|
||||||
|
var combined = ""
|
||||||
|
for item in contentArray {
|
||||||
|
if let text = extractText(from: item) {
|
||||||
|
combined += text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if combined.isEmpty == false {
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
|
|
||||||
private let bubbleView = UIView()
|
private let bubbleView = UIView()
|
||||||
private let messageLabel = UILabel()
|
private let messageLabel = UILabel()
|
||||||
private let indicator = UIActivityIndicatorView(style: .medium)
|
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
@ -19,25 +18,29 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let maxWidth = UIScreen.width * 2.0 / 3.0 - 28
|
||||||
|
if messageLabel.preferredMaxLayoutWidth != maxWidth {
|
||||||
|
messageLabel.preferredMaxLayoutWidth = maxWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupViews() {
|
private func setupViews() {
|
||||||
contentView.addSubview(bubbleView)
|
contentView.addSubview(bubbleView)
|
||||||
bubbleView.layer.cornerRadius = 18
|
bubbleView.layer.cornerRadius = 18
|
||||||
bubbleView.layer.masksToBounds = true
|
bubbleView.layer.masksToBounds = true
|
||||||
bubbleView.snp.makeConstraints { make in
|
bubbleView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
make.top.equalToSuperview().offset(6)
|
bubbleView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
make.bottom.equalToSuperview().offset(-6)
|
|
||||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
|
||||||
}
|
|
||||||
|
|
||||||
bubbleView.addSubview(messageLabel)
|
bubbleView.addSubview(messageLabel)
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.font = .systemFont(ofSize: 16)
|
messageLabel.font = .systemFont(ofSize: 16)
|
||||||
messageLabel.snp.makeConstraints { make in
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14))
|
messageLabel.textAlignment = .left
|
||||||
}
|
messageLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
messageLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
contentView.addSubview(indicator)
|
messageLabel.preferredMaxLayoutWidth = UIScreen.width * 2.0 / 3.0 - 28
|
||||||
indicator.hidesWhenStopped = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(message: StreamChatMessageModel) {
|
func configure(message: StreamChatMessageModel) {
|
||||||
|
|
@ -49,32 +52,32 @@ final class StreamChatBubbleCell: UITableViewCell {
|
||||||
bubbleView.snp.remakeConstraints { make in
|
bubbleView.snp.remakeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(6)
|
make.top.equalToSuperview().offset(6)
|
||||||
make.bottom.equalToSuperview().offset(-6)
|
make.bottom.equalToSuperview().offset(-6)
|
||||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
make.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0)
|
||||||
|
make.width.greaterThanOrEqualTo(60)
|
||||||
make.trailing.equalToSuperview().offset(-16)
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
}
|
}
|
||||||
indicator.snp.remakeConstraints { make in
|
|
||||||
make.centerY.equalTo(bubbleView)
|
|
||||||
make.trailing.equalTo(bubbleView.snp.leading).offset(-8)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
bubbleView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
bubbleView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
messageLabel.textColor = .white
|
messageLabel.textColor = .white
|
||||||
bubbleView.snp.remakeConstraints { make in
|
bubbleView.snp.remakeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(6)
|
make.top.equalToSuperview().offset(6)
|
||||||
make.bottom.equalToSuperview().offset(-6)
|
make.bottom.equalToSuperview().offset(-6)
|
||||||
make.width.lessThanOrEqualTo(UIScreen.width * 0.75)
|
make.width.lessThanOrEqualTo(UIScreen.width * 2.0 / 3.0)
|
||||||
|
make.width.greaterThanOrEqualTo(60)
|
||||||
make.leading.equalToSuperview().offset(16)
|
make.leading.equalToSuperview().offset(16)
|
||||||
}
|
}
|
||||||
indicator.snp.remakeConstraints { make in
|
|
||||||
make.centerY.equalTo(bubbleView)
|
|
||||||
make.leading.equalTo(bubbleView.snp.trailing).offset(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if message.isStreaming && !message.isSelf {
|
messageLabel.snp.remakeConstraints { make in
|
||||||
indicator.startAnimating()
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14))
|
||||||
} else {
|
}
|
||||||
indicator.stopAnimating()
|
}
|
||||||
|
|
||||||
|
func updateText(_ text: String) {
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
messageLabel.text = text
|
||||||
|
setNeedsLayout()
|
||||||
|
layoutIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue