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 {
VisualEffectView(effect: blurEffect)
VStack {
let image: UIImage = {
let actionType: AttachmentView.Action = {
if let _ = viewModel.error {
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
return .retry
} else {
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
return .remove
}
}()
Image(uiImage: image)
.foregroundColor(.white)
.padding()
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
.clipShape(Circle())
.padding()
Button {
action(actionType)
} label: {
let image: UIImage = {
switch actionType {
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 = {
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
} else {
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory)
return "…/\(total)"
}
}()
let subtitle: String = {
if let error = viewModel.error {
return error.localizedDescription
} else {
return "… remaining"
switch actionType {
case .remove:
if viewModel.progress.fractionCompleted < 1 {
return viewModel.remainTimeLocalizedString ?? ""
} else {
return ""
}
case .retry:
return viewModel.error?.localizedDescription ?? ""
}
}()
Text(title)
@ -92,10 +118,6 @@ public struct AttachmentView: View {
}
}
} // end ZStack
.onChange(of: viewModel.progress) { progress in
// not works
print(progress.completedUnitCount)
}
} // end body
}

View File

@ -32,12 +32,19 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
// output
@Published public private(set) var output: Output?
@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 var error: Error?
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(
api: APIService,
@ -49,26 +56,34 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
self.input = input
super.init()
// end init
self.displayLink = CADisplayLink(
target: self,
selector: #selector(AttachmentViewModel.step(displayLink:))
)
displayLink.add(to: .current, forMode: .common)
progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
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.fractionCompleted = progress.fractionCompleted
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
.store(in: &observations)
progress
.observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
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)")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
.store(in: &observations)
// Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress
// progress
// .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
// 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)")
// DispatchQueue.main.async {
// self.objectWillChange.send()
// }
// }
// .store(in: &observations)
$output
.map { output -> UIImage? in
@ -89,7 +104,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
do {
let output = try await load(input: input)
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(
apiService: self.api,
authContext: self.authContext
@ -103,6 +118,9 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
}
deinit {
displayLink.invalidate()
displayLink.remove(from: .current, forMode: .common)
switch output {
case .image:
// 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 {
public enum Input: Hashable {
case image(UIImage)

View File

@ -278,6 +278,11 @@ extension ComposeContentViewModel {
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
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 {
case contentMetaText?.textView:
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))
}
}