mirror of https://github.com/oxen-io/session-ios
Merge branch 'charlesmchen/typingIndicators5_'
commit
cc63c5307c
@ -0,0 +1,148 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(OWSTypingIndicatorCell)
|
||||||
|
public class TypingIndicatorCell: ConversationViewCell {
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public static let cellReuseIdentifier = "TypingIndicatorCell"
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
@objc
|
||||||
|
public required init(coder aDecoder: NSCoder) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
private let kAvatarSize: CGFloat = 36
|
||||||
|
private let kAvatarHSpacing: CGFloat = 8
|
||||||
|
|
||||||
|
private let avatarView = AvatarImageView()
|
||||||
|
private let bubbleView = OWSBubbleView()
|
||||||
|
private let typingIndicatorView = TypingIndicatorView()
|
||||||
|
private var viewConstraints = [NSLayoutConstraint]()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
commonInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commonInit() {
|
||||||
|
self.layoutMargins = .zero
|
||||||
|
self.contentView.layoutMargins = .zero
|
||||||
|
|
||||||
|
bubbleView.layoutMargins = .zero
|
||||||
|
|
||||||
|
bubbleView.addSubview(typingIndicatorView)
|
||||||
|
contentView.addSubview(bubbleView)
|
||||||
|
|
||||||
|
avatarView.autoSetDimension(.width, toSize: kAvatarSize)
|
||||||
|
avatarView.autoSetDimension(.height, toSize: kAvatarSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func loadForDisplay() {
|
||||||
|
guard let conversationStyle = self.conversationStyle else {
|
||||||
|
owsFailDebug("Missing conversationStyle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true)
|
||||||
|
typingIndicatorView.startAnimation()
|
||||||
|
|
||||||
|
viewConstraints.append(contentsOf: [
|
||||||
|
bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading),
|
||||||
|
bubbleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.gutterTrailing, relation: .greaterThanOrEqual),
|
||||||
|
bubbleView.autoPinTopToSuperviewMargin(withInset: 0),
|
||||||
|
bubbleView.autoPinBottomToSuperviewMargin(withInset: 0),
|
||||||
|
|
||||||
|
typingIndicatorView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.textInsetHorizontal),
|
||||||
|
typingIndicatorView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.textInsetHorizontal),
|
||||||
|
typingIndicatorView.autoPinTopToSuperviewMargin(withInset: conversationStyle.textInsetTop),
|
||||||
|
typingIndicatorView.autoPinBottomToSuperviewMargin(withInset: conversationStyle.textInsetBottom)
|
||||||
|
])
|
||||||
|
|
||||||
|
if let avatarView = configureAvatarView() {
|
||||||
|
contentView.addSubview(avatarView)
|
||||||
|
viewConstraints.append(contentsOf: [
|
||||||
|
bubbleView.autoPinLeading(toTrailingEdgeOf: avatarView, offset: kAvatarHSpacing),
|
||||||
|
bubbleView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView)
|
||||||
|
])
|
||||||
|
|
||||||
|
} else {
|
||||||
|
avatarView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAvatarView() -> UIView? {
|
||||||
|
guard let viewItem = self.viewItem else {
|
||||||
|
owsFailDebug("Missing viewItem")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let typingIndicators = viewItem.interaction as? TypingIndicatorInteraction else {
|
||||||
|
owsFailDebug("Missing typingIndicators")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard shouldShowAvatar() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let colorName = viewItem.authorConversationColorName else {
|
||||||
|
owsFailDebug("Missing authorConversationColorName")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let authorAvatarImage =
|
||||||
|
OWSContactAvatarBuilder(signalId: typingIndicators.recipientId,
|
||||||
|
colorName: ConversationColorName(rawValue: colorName),
|
||||||
|
diameter: UInt(kAvatarSize)).build() else {
|
||||||
|
owsFailDebug("Could build avatar image")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
avatarView.image = authorAvatarImage
|
||||||
|
return avatarView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowAvatar() -> Bool {
|
||||||
|
guard let viewItem = self.viewItem else {
|
||||||
|
owsFailDebug("Missing viewItem")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return viewItem.isGroupThread
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func cellSize() -> CGSize {
|
||||||
|
guard let conversationStyle = self.conversationStyle else {
|
||||||
|
owsFailDebug("Missing conversationStyle")
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
let insetsSize = CGSize(width: conversationStyle.textInsetHorizontal * 2,
|
||||||
|
height: conversationStyle.textInsetTop + conversationStyle.textInsetBottom)
|
||||||
|
let typingIndicatorSize = typingIndicatorView.sizeThatFits(.zero)
|
||||||
|
let bubbleSize = CGSizeAdd(insetsSize, typingIndicatorSize)
|
||||||
|
|
||||||
|
if shouldShowAvatar() {
|
||||||
|
let avatarSize = CGSize(width: kAvatarSize, height: kAvatarSize)
|
||||||
|
return CGSizeCeil(CGSize(width: avatarSize.width + kAvatarHSpacing + bubbleSize.width,
|
||||||
|
height: max(avatarSize.height, bubbleSize.height)))
|
||||||
|
} else {
|
||||||
|
return bubbleSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
|
||||||
|
NSLayoutConstraint.deactivate(viewConstraints)
|
||||||
|
viewConstraints = [NSLayoutConstraint]()
|
||||||
|
|
||||||
|
avatarView.image = nil
|
||||||
|
avatarView.removeFromSuperview()
|
||||||
|
|
||||||
|
typingIndicatorView.stopAnimation()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(OWSTypingIndicatorInteraction)
|
||||||
|
public class TypingIndicatorInteraction: TSInteraction {
|
||||||
|
@objc
|
||||||
|
public static let TypingIndicatorId = "TypingIndicator"
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func isDynamicInteraction() -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func interactionType() -> OWSInteractionType {
|
||||||
|
return .typingIndicator
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
@objc
|
||||||
|
public required init(coder aDecoder: NSCoder) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
@objc
|
||||||
|
public required init(dictionary dictionaryValue: [AnyHashable: Any]!) throws {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public let recipientId: String
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public init(thread: TSThread, timestamp: UInt64, recipientId: String) {
|
||||||
|
self.recipientId = recipientId
|
||||||
|
|
||||||
|
super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId,
|
||||||
|
timestamp: timestamp, in: thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func save(with transaction: YapDatabaseReadWriteTransaction!) {
|
||||||
|
owsFailDebug("The transient interaction should not be saved in the database.")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
@objc class TypingIndicatorView: UIStackView {
|
||||||
|
// This represents the spacing between the dots
|
||||||
|
// _at their max size_.
|
||||||
|
private let kDotMaxHSpacing: CGFloat = 3
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public static let kMinRadiusPt: CGFloat = 6
|
||||||
|
@objc
|
||||||
|
public static let kMaxRadiusPt: CGFloat = 8
|
||||||
|
|
||||||
|
private let dot1 = DotView(dotType: .dotType1)
|
||||||
|
private let dot2 = DotView(dotType: .dotType2)
|
||||||
|
private let dot3 = DotView(dotType: .dotType3)
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
required init(coder aDecoder: NSCoder) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
// init(arrangedSubviews:...) is not a designated initializer.
|
||||||
|
for dot in dots() {
|
||||||
|
addArrangedSubview(dot)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.axis = .horizontal
|
||||||
|
self.spacing = kDotMaxHSpacing
|
||||||
|
self.alignment = .center
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||||
|
return CGSize(width: TypingIndicatorView.kMaxRadiusPt * 3 + kDotMaxHSpacing * 2, height: TypingIndicatorView.kMaxRadiusPt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dots() -> [DotView] {
|
||||||
|
return [dot1, dot2, dot3]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public func startAnimation() {
|
||||||
|
for dot in dots() {
|
||||||
|
dot.startAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public func stopAnimation() {
|
||||||
|
for dot in dots() {
|
||||||
|
dot.stopAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum DotType {
|
||||||
|
case dotType1
|
||||||
|
case dotType2
|
||||||
|
case dotType3
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DotView: UIView {
|
||||||
|
private let dotType: DotType
|
||||||
|
|
||||||
|
private let shapeLayer = CAShapeLayer()
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
notImplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(dotType: DotType) {
|
||||||
|
self.dotType = dotType
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
autoSetDimension(.width, toSize: kMaxRadiusPt)
|
||||||
|
autoSetDimension(.height, toSize: kMaxRadiusPt)
|
||||||
|
|
||||||
|
layer.addSublayer(shapeLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func startAnimation() {
|
||||||
|
stopAnimation()
|
||||||
|
|
||||||
|
let baseColor = (Theme.isDarkThemeEnabled
|
||||||
|
? UIColor(rgbHex: 0xBBBDBE)
|
||||||
|
: UIColor(rgbHex: 0x636467))
|
||||||
|
let timeIncrement: CFTimeInterval = 0.15
|
||||||
|
var colorValues = [CGColor]()
|
||||||
|
var pathValues = [CGPath]()
|
||||||
|
var keyTimes = [CFTimeInterval]()
|
||||||
|
var animationDuration: CFTimeInterval = 0
|
||||||
|
|
||||||
|
let addDotKeyFrame = { (keyFrameTime: CFTimeInterval, progress: CGFloat) in
|
||||||
|
let dotColor = baseColor.withAlphaComponent(CGFloatLerp(0.4, 1.0, progress))
|
||||||
|
colorValues.append(dotColor.cgColor)
|
||||||
|
let radius = CGFloatLerp(TypingIndicatorView.kMinRadiusPt, TypingIndicatorView.kMaxRadiusPt, progress)
|
||||||
|
let margin = (TypingIndicatorView.kMaxRadiusPt - radius) * 0.5
|
||||||
|
let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: radius, height: radius))
|
||||||
|
pathValues.append(bezierPath.cgPath)
|
||||||
|
|
||||||
|
keyTimes.append(keyFrameTime)
|
||||||
|
animationDuration = max(animationDuration, keyFrameTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All animations in the group apparently need to have the same number
|
||||||
|
// of keyframes, and use the same timing.
|
||||||
|
switch dotType {
|
||||||
|
case .dotType1:
|
||||||
|
addDotKeyFrame(0 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(1 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(2 * timeIncrement, 1.0)
|
||||||
|
addDotKeyFrame(3 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(4 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(5 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(6 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(10 * timeIncrement, 0.0)
|
||||||
|
break
|
||||||
|
case .dotType2:
|
||||||
|
addDotKeyFrame(0 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(1 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(2 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(3 * timeIncrement, 1.0)
|
||||||
|
addDotKeyFrame(4 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(5 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(6 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(10 * timeIncrement, 0.0)
|
||||||
|
break
|
||||||
|
case .dotType3:
|
||||||
|
addDotKeyFrame(0 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(1 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(2 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(3 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(4 * timeIncrement, 1.0)
|
||||||
|
addDotKeyFrame(5 * timeIncrement, 0.5)
|
||||||
|
addDotKeyFrame(6 * timeIncrement, 0.0)
|
||||||
|
addDotKeyFrame(10 * timeIncrement, 0.0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let makeAnimation: (String, [Any]) -> CAKeyframeAnimation = { (keyPath, values) in
|
||||||
|
let animation = CAKeyframeAnimation()
|
||||||
|
animation.keyPath = keyPath
|
||||||
|
animation.values = values
|
||||||
|
animation.duration = animationDuration
|
||||||
|
return animation
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupAnimation = CAAnimationGroup()
|
||||||
|
groupAnimation.animations = [
|
||||||
|
makeAnimation("fillColor", colorValues),
|
||||||
|
makeAnimation("path", pathValues)
|
||||||
|
]
|
||||||
|
groupAnimation.duration = animationDuration
|
||||||
|
groupAnimation.repeatCount = MAXFLOAT
|
||||||
|
|
||||||
|
shapeLayer.add(groupAnimation, forKey: UUID().uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func stopAnimation() {
|
||||||
|
shapeLayer.removeAllAnimations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue