Skip to content

Instantly share code, notes, and snippets.

@umurgdk
Created December 30, 2022 18:06
Show Gist options
  • Save umurgdk/f09ad68e59880a88e2be5703019346dd to your computer and use it in GitHub Desktop.
Save umurgdk/f09ad68e59880a88e2be5703019346dd to your computer and use it in GitHub Desktop.
Mastodon AppKit cell view
import AppKit
import NukeUI
import PinLayout
final class FeedMediaGrid: BaseNSView {
let imageView1 = LazyImageView()
let imageView2 = LazyImageView()
let imageView3 = LazyImageView()
let imageView4 = LazyImageView()
var allImageViews: [LazyImageView] {
[imageView1, imageView2, imageView3, imageView4]
}
let preferredRatio: CGFloat = 1.75
var imageCount = 1
override var wantsUpdateLayer: Bool { true }
override func setupViewHieararchy() {
addSubview(imageView1)
addSubview(imageView2)
addSubview(imageView3)
addSubview(imageView4)
allImageViews.forEach {
$0.imageView.resizingMode = .fill
}
wantsLayer = true
layer?.cornerRadius = 6
layer?.borderWidth = 1
}
func configure(with attachments: [MediaAttachment]) {
let limitedAttachments = attachments.prefix(4)
zip(allImageViews, limitedAttachments).forEach { $0.url = $1.previewURL ?? $1.url }
imageCount = limitedAttachments.count
allImageViews.prefix(imageCount).forEach { $0.isHidden = false }
allImageViews.dropFirst(imageCount).forEach { $0.isHidden = true }
}
override func layout() {
super.layout()
if imageCount == 1 {
fillLayout()
} else if imageCount == 2 {
sideBySideLayout()
} else if imageCount == 3 {
brickLayout()
} else if imageCount == 4 {
gridLayout()
}
}
func fillLayout() {
imageView1.pin.all()
}
func sideBySideLayout() {
imageView1.pin.topLeft().bottom().right(to: edge.hCenter).marginRight(2)
imageView2.pin.topRight().bottom().left(to: edge.hCenter).marginLeft(2)
}
func brickLayout() {
imageView1.pin.topLeft().bottom().right(to: edge.hCenter).marginRight(2)
imageView2.pin.topRight().bottom(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginBottom(2)
imageView3.pin.bottomRight().top(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginTop(2)
}
func gridLayout() {
imageView1.pin.topLeft().bottom(to: edge.vCenter).right(to: edge.hCenter).marginRight(2).marginBottom(2)
imageView2.pin.topRight().bottom(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginBottom(2)
imageView3.pin.bottomLeft().top(to: edge.vCenter).right(to: edge.hCenter).marginRight(2).marginTop(2)
imageView4.pin.bottomRight().top(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginTop(2)
}
override func updateLayer() {
super.updateLayer()
if effectiveAppearance.name == NSAppearance.Name.darkAqua {
layer?.backgroundColor = .black
layer?.borderColor = NSColor.separatorColor.cgColor
} else {
layer?.backgroundColor = .white
layer?.borderColor = NSColor.black.withAlphaComponent(0.15).cgColor
}
}
}
import AppKit
import NukeUI
class FeedItemCell: BaseNSTableCellView, ReuseIdentifiable {
private let avatar = LazyImageView()
private let displayName = NSTextField(labelWithString: "")
private let username = NSTextField(labelWithString: "")
private let body = NSTextField(wrappingLabelWithString: "")
private let separator = NSBox()
private let mediaGrid = FeedMediaGrid()
private let typeView = FeedItemStatusView()
private let date = NSTextField(labelWithString: "")
private let actionBar = FeedItemActionBar()
override var isFlipped: Bool { false }
override class var requiresConstraintBasedLayout: Bool { true }
func configure(with status: Status, statusContentCache: inout [String: NSAttributedString]) {
let actualStatus = status.reblog ?? status
avatar.url = actualStatus.account.avatar
displayName.stringValue = actualStatus.account.displayName.isEmpty ? actualStatus.account.username : actualStatus.account.displayName
username.stringValue = actualStatus.account.username
body.attributedStringValue = statusContentCache[actualStatus.id] ?? renderStatusBody(actualStatus.content, into: &statusContentCache)
actionBar.configure(
repliesCount: actualStatus.numberOfReplies,
boostCount: actualStatus.numberOfReblogs,
isFavorited: false,
favoriteCount: actualStatus.numberOfFavorites,
isBookmarked: false)
date.stringValue = formatFeedItemDate(actualStatus.createdAt)
mediaGrid.configure(with: actualStatus.mediaAttachments)
if actualStatus.mediaAttachments.isEmpty {
mediaGrid.removeFromSuperview()
} else {
addSubview(mediaGrid)
}
if status.reblog != nil {
typeView.configure(type: .reblog, text: "\(status.account.displayName) boosted")
addSubview(typeView)
} else if status.replyingToStatusID != nil {
typeView.configure(type: .reply, text: "reply to another post")
} else {
typeView.removeFromSuperview()
}
}
func renderStatusBody(_ text: String, into cache: inout [String: NSAttributedString]) -> NSAttributedString {
let options: [NSMutableAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue,
]
let str = NSMutableAttributedString(
html: text.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)!,
options: options,
documentAttributes: nil
)!
str.addAttributes([
.font: NSFont.systemFont(ofSize: 14),
.foregroundColor: NSColor.labelColor
], range: NSRange(location: 0, length: str.length))
var range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines)
// Trim leading characters from character set.
while range.length != 0 && range.location == 0 {
str.replaceCharacters(in: range, with: "")
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines)
}
// Trim trailing characters from character set.
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards)
while range.length != 0 && NSMaxRange(range) == str.length {
str.replaceCharacters(in: range, with: "")
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards)
}
return str
}
override func prepareForReuse() {
super.prepareForReuse()
NSLayoutConstraint.deactivate(previousConstraints)
didCreateLayoutConstraints = false
needsUpdateConstraints = true
}
override func setupViewHieararchy() {
addSubview(avatar)
addSubview(displayName)
addSubview(username)
addSubview(body)
addSubview(separator)
addSubview(date)
addSubview(actionBar)
avatar.wantsLayer = true
avatar.layer?.cornerRadius = 6
username.textColor = .tertiaryLabelColor
separator.boxType = .custom
separator.fillColor = .labelColor.withAlphaComponent(0.05)
separator.borderWidth = 0
body.maximumNumberOfLines = 0
body.lineBreakStrategy = .pushOut
body.lineBreakMode = .byWordWrapping
body.allowsEditingTextAttributes = true
date.textColor = .secondaryLabelColor
avatar.translatesAutoresizingMaskIntoConstraints = false
displayName.translatesAutoresizingMaskIntoConstraints = false
username.translatesAutoresizingMaskIntoConstraints = false
body.translatesAutoresizingMaskIntoConstraints = false
separator.translatesAutoresizingMaskIntoConstraints = false
mediaGrid.translatesAutoresizingMaskIntoConstraints = false
typeView.translatesAutoresizingMaskIntoConstraints = false
date.translatesAutoresizingMaskIntoConstraints = false
actionBar.translatesAutoresizingMaskIntoConstraints = false
displayName.setContentCompressionResistancePriority(.required, for: .vertical)
username.setContentCompressionResistancePriority(.required, for: .vertical)
body.setContentCompressionResistancePriority(.required, for: .vertical)
typeView.setContentCompressionResistancePriority(.required, for: .vertical)
date.setContentCompressionResistancePriority(.required, for: .vertical)
}
var didCreateLayoutConstraints = false
var previousConstraints: [NSLayoutConstraint] = []
override func updateConstraints() {
if !didCreateLayoutConstraints {
didCreateLayoutConstraints = true
previousConstraints.removeAll()
let hasTypeView = typeView.superview == self
let hasMediaGrid = mediaGrid.superview == self
if hasTypeView {
previousConstraints += [
typeView.topAnchor.constraint(equalTo: topAnchor, constant: 10),
typeView.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: -16),
typeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
]
}
previousConstraints += [
avatar.topAnchor.constraint(
equalTo: hasTypeView ? typeView.bottomAnchor : topAnchor,
constant: hasTypeView ? 4 : 16),
avatar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
avatar.widthAnchor.constraint(equalToConstant: 40),
avatar.heightAnchor.constraint(equalTo: avatar.widthAnchor),
displayName.topAnchor.constraint(equalTo: avatar.topAnchor, constant: 2),
displayName.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 8),
displayName.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
date.firstBaselineAnchor.constraint(equalTo: displayName.firstBaselineAnchor),
date.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
username.topAnchor.constraint(equalTo: displayName.bottomAnchor, constant: 2),
username.leadingAnchor.constraint(equalTo: displayName.leadingAnchor),
username.trailingAnchor.constraint(equalTo: displayName.trailingAnchor),
body.topAnchor.constraint(equalTo: username.bottomAnchor, constant: 6),
body.leadingAnchor.constraint(equalTo: username.leadingAnchor),
body.trailingAnchor.constraint(equalTo: username.trailingAnchor),
separator.leadingAnchor.constraint(equalTo: leadingAnchor),
separator.bottomAnchor.constraint(equalTo: bottomAnchor),
separator.trailingAnchor.constraint(equalTo: trailingAnchor),
separator.heightAnchor.constraint(equalToConstant: 1)
]
if hasMediaGrid {
previousConstraints += [
// We have media grid so media grid should have bottom constraint
mediaGrid.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 8),
mediaGrid.leadingAnchor.constraint(equalTo: body.leadingAnchor),
mediaGrid.trailingAnchor.constraint(equalTo: body.trailingAnchor),
mediaGrid.widthAnchor.constraint(equalTo: mediaGrid.heightAnchor, multiplier: 1.75)
]
}
previousConstraints += [
hasMediaGrid
? actionBar.topAnchor.constraint(equalTo: mediaGrid.bottomAnchor, constant: 8)
: actionBar.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 8),
actionBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
actionBar.leadingAnchor.constraint(equalTo: body.leadingAnchor),
actionBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
]
NSLayoutConstraint.activate(previousConstraints)
}
super.updateConstraints()
}
}
@umurgdk
Copy link
Author

umurgdk commented Dec 30, 2022

There is nothing special about the BaseNSView and BaseNSTableCellView they basically implement required init(coder: NSCoder) and marks it as unavailable so that subclasses doesn't have to implement that unnecessary initializer:

@available(*, unavailable)
public required init?(coder: NSCoder) {
    fatalError("init?(coder:) has not been implemented")
}

One little convenient method defined on BaseNSView is setupViewHieararchy:

class BaseNSView: NSView {
    init() { 
        super.init(frame: .zero)
        setupViewHierarchy()
    }

    func setupViewHierarchy() { }
}

@mackuba
Copy link

mackuba commented Dec 30, 2022

Ooh that's a nice trick with the unavailable initWithCoder, I'm stealing this! πŸ‘

@mackuba
Copy link

mackuba commented Dec 30, 2022

Lol, this is so annoying πŸ˜…

        avatar.translatesAutoresizingMaskIntoConstraints = false
        displayName.translatesAutoresizingMaskIntoConstraints = false
        username.translatesAutoresizingMaskIntoConstraints = false
        body.translatesAutoresizingMaskIntoConstraints = false
        separator.translatesAutoresizingMaskIntoConstraints = false
        mediaGrid.translatesAutoresizingMaskIntoConstraints = false
        typeView.translatesAutoresizingMaskIntoConstraints = false
        date.translatesAutoresizingMaskIntoConstraints = false
        actionBar.translatesAutoresizingMaskIntoConstraints = false

@umurgdk
Copy link
Author

umurgdk commented Dec 30, 2022 via email

@mackuba
Copy link

mackuba commented Dec 30, 2022

I don't understand the while range.length != 0 && range.location == 0 { ... part - shouldn't the text.trimmingCharacters(in: .whitespacesAndNewlines) in the initializer already have trimmed everything there is to trim on the beginning/end? Why would there still be whitespace to trim there after that?

@umurgdk
Copy link
Author

umurgdk commented Dec 30, 2022

@mackuba please ignore that part 🀣 , I was just reading that part while pasting into gist and I was like, what the hell am i doing there?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment