feat: add simple progress remain time estimate

This commit is contained in:
CMK 2022-11-10 18:36:36 +08:00
parent fec7db2f41
commit d6b90f40bd
4 changed files with 158 additions and 32 deletions

View File

@ -53,32 +53,58 @@ public struct AttachmentView: View {
if viewModel.output != nil { if viewModel.output != nil {
VisualEffectView(effect: blurEffect) VisualEffectView(effect: blurEffect)
VStack { VStack {
let image: UIImage = { let actionType: AttachmentView.Action = {
if let _ = viewModel.error { if let _ = viewModel.error {
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate) return .retry
} else { } else {
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate) return .remove
} }
}() }()
Image(uiImage: image) Button {
.foregroundColor(.white) action(actionType)
.padding() } label: {
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color)) let image: UIImage = {
.clipShape(Circle()) switch actionType {
.padding() case .remove:
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
case .retry:
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
}
}()
Image(uiImage: image)
.foregroundColor(.white)
.padding()
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
.overlay(
CircleProgressView(progress: viewModel.fractionCompleted)
.animation(.default, value: viewModel.fractionCompleted)
)
.clipShape(Circle())
.padding()
}
let title: String = { let title: String = {
if let _ = viewModel.error { switch actionType {
case .remove:
let totalSizeInByte = viewModel.outputSizeInByte
let uploadSizeInByte = Double(totalSizeInByte) * viewModel.progress.fractionCompleted
let total = ByteCountFormatter.string(fromByteCount: Int64(totalSizeInByte), countStyle: .memory)
let upload = ByteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte), countStyle: .memory)
return "\(upload)/\(total)"
case .retry:
return "Upload Failed" // TODO: i18n return "Upload Failed" // TODO: i18n
} else {
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory)
return "…/\(total)"
} }
}() }()
let subtitle: String = { let subtitle: String = {
if let error = viewModel.error { switch actionType {
return error.localizedDescription case .remove:
} else { if viewModel.progress.fractionCompleted < 1 {
return "… remaining" return viewModel.remainTimeLocalizedString ?? ""
} else {
return ""
}
case .retry:
return viewModel.error?.localizedDescription ?? ""
} }
}() }()
Text(title) Text(title)
@ -92,10 +118,6 @@ public struct AttachmentView: View {
} }
} }
} // end ZStack } // end ZStack
.onChange(of: viewModel.progress) { progress in
// not works
print(progress.completedUnitCount)
}
} // end body } // end body
} }

View File

@ -32,12 +32,19 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
// output // output
@Published public private(set) var output: Output? @Published public private(set) var output: Output?
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail @Published public private(set) var thumbnail: UIImage? // original size image thumbnail
@Published public private(set) var outputSizeInByte: Int = 0 @Published public private(set) var outputSizeInByte: Int64 = 0
@Published public var uploadResult: UploadResult? @Published public var uploadResult: UploadResult?
@Published var error: Error? @Published var error: Error?
let progress = Progress() // upload progress let progress = Progress() // upload progress
@Published var fractionCompleted: Double = 0
var displayLink: CADisplayLink!
private var lastTimestamp: TimeInterval?
private var lastUploadSizeInByte: Int64 = 0
private var averageUploadSpeedInByte: Int64 = 0
@Published var remainTimeLocalizedString: String?
public init( public init(
api: APIService, api: APIService,
@ -49,26 +56,34 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
self.input = input self.input = input
super.init() super.init()
// end init // end init
self.displayLink = CADisplayLink(
target: self,
selector: #selector(AttachmentViewModel.step(displayLink:))
)
displayLink.add(to: .current, forMode: .common)
progress progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in .observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return } guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
self.fractionCompleted = progress.fractionCompleted
DispatchQueue.main.async { DispatchQueue.main.async {
self.objectWillChange.send() self.objectWillChange.send()
} }
} }
.store(in: &observations) .store(in: &observations)
progress // Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress
.observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in // progress
guard let self = self else { return } // .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") // guard let self = self else { return }
DispatchQueue.main.async { // self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
self.objectWillChange.send() // DispatchQueue.main.async {
} // self.objectWillChange.send()
} // }
.store(in: &observations) // }
// .store(in: &observations)
$output $output
.map { output -> UIImage? in .map { output -> UIImage? in
@ -89,7 +104,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
do { do {
let output = try await load(input: input) let output = try await load(input: input)
self.output = output self.output = output
self.outputSizeInByte = output.asAttachment.sizeInByte ?? 0 self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
let uploadResult = try await self.upload(context: .init( let uploadResult = try await self.upload(context: .init(
apiService: self.api, apiService: self.api,
authContext: self.authContext authContext: self.authContext
@ -103,6 +118,9 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
} }
deinit { deinit {
displayLink.invalidate()
displayLink.remove(from: .current, forMode: .common)
switch output { switch output {
case .image: case .image:
// FIXME: // FIXME:
@ -115,6 +133,58 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
} }
} }
// calculate the upload speed
// ref: https://stackoverflow.com/a/3841706/3797903
extension AttachmentViewModel {
static var SpeedSmoothingFactor = 0.4
static let remainsTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
@objc private func step(displayLink: CADisplayLink) {
guard let lastTimestamp = self.lastTimestamp else {
self.lastTimestamp = displayLink.timestamp
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
return
}
let duration = displayLink.timestamp - lastTimestamp
guard duration >= 1.0 else { return } // update every 1 sec
let old = self.lastUploadSizeInByte
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
let newSpeed = self.lastUploadSizeInByte - old
let lastAverageSpeed = self.averageUploadSpeedInByte
let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed))
let remainSizeInByte = Double(outputSizeInByte) * (1 - progress.fractionCompleted)
let speed = Double(newAverageSpeed)
if speed != .zero {
// estimate by speed
let uploadRemainTimeInSecond = remainSizeInByte / speed
// estimate by progress 1s for 10%
let remainPercentage = 1 - progress.fractionCompleted
let estimateRemainTimeByProgress = remainPercentage / 0.1
// max estimate
let remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond)
remainTimeLocalizedString = string
// print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)")
} else {
remainTimeLocalizedString = nil
}
self.lastTimestamp = displayLink.timestamp
self.averageUploadSpeedInByte = newAverageSpeed
}
}
extension AttachmentViewModel { extension AttachmentViewModel {
public enum Input: Hashable { public enum Input: Hashable {
case image(UIImage) case image(UIImage)

View File

@ -278,6 +278,11 @@ extension ComposeContentViewModel {
// MARK: - UITextViewDelegate // MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate { extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) { public func textViewDidBeginEditing(_ textView: UITextView) {
// Note:
// Xcode warning:
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
//
// Just ignore the warning and see what will happen
switch textView { switch textView {
case contentMetaText?.textView: case contentMetaText?.textView:
isContentEditing = true isContentEditing = true

View File

@ -0,0 +1,29 @@
//
// CircleProgressView.swift
//
//
// Created by MainasuK on 2022/11/10.
//
import Foundation
import SwiftUI
/// https://stackoverflow.com/a/71467536/3797903
struct CircleProgressView: View {
let progress: Double
var body: some View {
let lineWidth: CGFloat = 4
let tintColor = Color.white
ZStack {
Circle()
.trim(from: 0.0, to: CGFloat(progress))
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .bevel))
.foregroundColor(tintColor)
.rotationEffect(Angle(degrees: 270.0))
}
.padding(ceil(lineWidth / 2))
}
}