feat: implement boost toot. Add stacked style avatar

This commit is contained in:
CMK 2021-03-10 13:36:01 +08:00
parent 2ac2eb7c77
commit 1256ef1d8e
6 changed files with 323 additions and 5 deletions

View File

@ -149,6 +149,7 @@
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */; };
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
@ -396,6 +397,7 @@
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerView.swift; sourceTree = "<group>"; };
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -1168,6 +1170,7 @@
children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */,
);
path = Container;
sourceTree = "<group>";
@ -1684,6 +1687,7 @@
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,

View File

@ -86,13 +86,23 @@ extension StatusSection {
return L10n.Common.Controls.Status.userBoosted(name)
}()
// set name username avatar
// set name username
cell.statusView.nameLabel.text = {
let author = (toot.reblog ?? toot).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL()))
// set avatar
if let reblog = toot.reblog {
cell.statusView.avatarButton.isHidden = true
cell.statusView.avatarStackedContainerButton.isHidden = false
cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL()))
} else {
cell.statusView.avatarButton.isHidden = false
cell.statusView.avatarStackedContainerButton.isHidden = true
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL()))
}
// set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)

View File

@ -23,7 +23,13 @@ extension AvatarConfigurableView {
public func configure(with configuration: AvatarConfigurableViewConfiguration) {
let placeholderImage: UIImage = {
let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill)
return placeholderImage.af.imageRoundedIntoCircle()
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
.af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true)
} else {
return placeholderImage.af.imageRoundedIntoCircle()
}
}()
// cancel previous task
@ -65,7 +71,8 @@ extension AvatarConfigurableView {
)
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarImageView.layer.cornerCurve = .circular
avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarImageView.af.setImage(
@ -92,7 +99,7 @@ extension AvatarConfigurableView {
)
avatarButton.layer.masksToBounds = true
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarButton.layer.cornerCurve = .continuous
avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarButton.af.setImage(

View File

@ -45,10 +45,18 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.moveToTopGapAction(action)
}),
UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstReblogToot(action)
}),
UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstPollToot(action)
}),
UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstAudioToot(action)
}),
// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in
// guard let self = self else { return }
// self.moveToFirstReplyToot(action)
@ -101,6 +109,26 @@ extension HomeTimelineViewController {
}
}
@objc private func moveToFirstReblogToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
return homeTimelineIndex.toot.reblog != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found reblog toot")
}
}
@objc private func moveToFirstPollToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
@ -122,6 +150,27 @@ extension HomeTimelineViewController {
}
}
@objc private func moveToFirstAudioToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found audio toot")
}
}
@objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()

View File

@ -0,0 +1,193 @@
//
// AvatarStackContainerView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-10.
//
import UIKit
final class AvatarStackedImageView: UIImageView { }
// MARK: - AvatarConfigurableView
extension AvatarStackedImageView: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) }
static var configurableAvatarImageCornerRadius: CGFloat { 4 }
var configurableAvatarImageView: UIImageView? { self }
var configurableAvatarButton: UIButton? { nil }
}
import os.log
import UIKit
extension UIControl.State: Hashable { }
final class AvatarStackContainerButton: UIControl {
static let containerSize = CGSize(width: 42, height: 42)
static let maskOffset: CGFloat = 2
// UIControl.Event - Application: 0x0F000000
static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000
var primaryActionState: UIControl.State = .normal
let topLeadingAvatarStackedImageView = AvatarStackedImageView()
let bottomTrailingAvatarStackedImageView = AvatarStackedImageView()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension AvatarStackContainerButton {
private func _init() {
topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topLeadingAvatarStackedImageView)
NSLayoutConstraint.activate([
topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor),
topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh),
topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh),
])
bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomTrailingAvatarStackedImageView)
NSLayoutConstraint.activate([
bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh),
bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh),
])
// mask topLeadingAvatarStackedImageView
let offset: CGFloat = 2
let path: CGPath = {
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.containerSize))
if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft {
path.addPath(UIBezierPath(
roundedRect: CGRect(
x: AvatarStackedImageView.configurableAvatarImageSize.width + offset,
y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset,
width: AvatarStackedImageView.configurableAvatarImageSize.width,
height: AvatarStackedImageView.configurableAvatarImageSize.height
),
cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius
).cgPath)
} else {
path.addPath(UIBezierPath(
roundedRect: CGRect(
x: AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset,
y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset,
width: AvatarStackedImageView.configurableAvatarImageSize.width,
height: AvatarStackedImageView.configurableAvatarImageSize.height
),
cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius
).cgPath)
}
return path
}()
let maskShapeLayer = CAShapeLayer()
maskShapeLayer.backgroundColor = UIColor.black.cgColor
maskShapeLayer.fillRule = .evenOdd
maskShapeLayer.path = path
topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer
topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill)
bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill)
}
override var intrinsicContentSize: CGSize {
return AvatarStackContainerButton.containerSize
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
defer { updateAppearance() }
updateState(touch: touch, event: event)
return super.beginTracking(touch, with: event)
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
defer { updateAppearance() }
updateState(touch: touch, event: event)
return super.continueTracking(touch, with: event)
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
defer { updateAppearance() }
resetState()
if let touch = touch {
if AvatarStackContainerButton.isTouching(touch, view: self, event: event) {
sendActions(for: AvatarStackContainerButton.primaryAction)
} else {
// do nothing
}
}
super.endTracking(touch, with: event)
}
override func cancelTracking(with event: UIEvent?) {
defer { updateAppearance() }
resetState()
super.cancelTracking(with: event)
}
}
extension AvatarStackContainerButton {
private func updateAppearance() {
topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0
bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0
}
private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool {
let location = touch.location(in: view)
return view.point(inside: location, with: event)
}
private func resetState() {
primaryActionState = .normal
}
private func updateState(touch: UITouch, event: UIEvent?) {
primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AvatarStackContainerButton_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 42) {
let avatarStackContainerButton = AvatarStackContainerButton()
avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42),
avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42),
])
return avatarStackContainerButton
}
.previewLayout(.fixed(width: 42, height: 42))
}
}
#endif

View File

@ -60,6 +60,7 @@ final class StatusView: UIView {
button.setImage(placeholderImage, for: .normal)
return button
}()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
let nameLabel: UILabel = {
let label = UILabel()
@ -238,6 +239,14 @@ extension StatusView {
avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarStackedContainerButton)
NSLayoutConstraint.activate([
avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor),
avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor),
avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
// author meta container: [title container | subtitle container]
let authorMetaContainerStackView = UIStackView()
@ -360,6 +369,7 @@ extension StatusView {
pollStatusStackView.isHidden = true
audioView.isHidden = true
avatarStackedContainerButton.isHidden = true
contentWarningBlurContentImageView.isHidden = true
statusContentWarningContainerStackView.isHidden = true
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
@ -429,6 +439,7 @@ import SwiftUI
struct StatusView_Previews: PreviewProvider {
static let avatarFlora = UIImage(named: "tiraya-adam")
static let avatarMarkus = UIImage(named: "markus-spiske")
static var previews: some View {
Group {
@ -443,6 +454,49 @@ struct StatusView_Previews: PreviewProvider {
return statusView
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("Normal")
UIViewPreview(width: 375) {
let statusView = StatusView()
statusView.headerContainerStackView.isHidden = false
statusView.avatarButton.isHidden = true
statusView.avatarStackedContainerButton.isHidden = false
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarFlora
)
)
statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarMarkus
)
)
return statusView
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("Boost")
UIViewPreview(width: 375) {
let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500))
statusView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarFlora
)
)
statusView.headerContainerStackView.isHidden = false
let images = MosaicImageView_Previews.images
let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
imageView.image = images[i]
}
statusView.statusMosaicImageViewContainer.isHidden = false
statusView.statusMosaicImageViewContainer.blurVisualEffectView.isHidden = true
statusView.isStatusTextSensitive = false
return statusView
}
.previewLayout(.fixed(width: 375, height: 380))
.previewDisplayName("Image Meida")
UIViewPreview(width: 375) {
let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500))
statusView.configure(
@ -466,6 +520,7 @@ struct StatusView_Previews: PreviewProvider {
return statusView
}
.previewLayout(.fixed(width: 375, height: 380))
.previewDisplayName("Content Sensitive")
}
}