mirror of https://github.com/oxen-io/session-ios
feat: emoji picker view
parent
5f4758d36a
commit
913939616e
@ -0,0 +1,128 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
class EmojiPickerSheet: BaseVC {
|
||||
let completionHandler: (EmojiWithSkinTones?) -> Void
|
||||
let dismissHandler: () -> Void
|
||||
|
||||
// MARK: Components
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
let result = UIView()
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
result.addSubview(line)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
return result
|
||||
}()
|
||||
|
||||
private let collectionView = EmojiPickerCollectionView()
|
||||
|
||||
private lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.backgroundColor = isDarkMode ? .ows_gray90 : .ows_white
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void) {
|
||||
self.completionHandler = completionHandler
|
||||
self.dismissHandler = dismissHandler
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
public required init() {
|
||||
fatalError("init() has not been implemented")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
view.addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
contentView.set(.height, to: 440)
|
||||
populateContentView()
|
||||
}
|
||||
|
||||
private func populateContentView() {
|
||||
let topStackView = UIStackView()
|
||||
topStackView.axis = .horizontal
|
||||
topStackView.isLayoutMarginsRelativeArrangement = true
|
||||
topStackView.spacing = 8
|
||||
|
||||
topStackView.addArrangedSubview(searchBar)
|
||||
|
||||
contentView.addSubview(topStackView)
|
||||
|
||||
topStackView.autoPinWidthToSuperview()
|
||||
topStackView.autoPinEdge(toSuperviewEdge: .top)
|
||||
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.autoPinEdge(.top, to: .bottom, of: searchBar)
|
||||
collectionView.autoPinEdge(.bottom, to: .bottom, of: contentView)
|
||||
collectionView.autoPinWidthToSuperview()
|
||||
collectionView.pickerDelegate = self
|
||||
collectionView.alwaysBounceVertical = true
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
coordinator.animate(alongsideTransition: { _ in
|
||||
self.collectionView.reloadData()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// Ensure the scrollView's layout has completed
|
||||
// as we're about to use its bounds to calculate
|
||||
// the masking view and contentOffset.
|
||||
contentView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
let touch = touches.first!
|
||||
let location = touch.location(in: view)
|
||||
if contentView.frame.contains(location) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
dismiss(animated: true, completion: dismissHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate {
|
||||
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) {
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) {
|
||||
completionHandler(emoji)
|
||||
dismiss(animated: true, completion: dismissHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerSheet: UISearchBarDelegate {
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
collectionView.searchText = searchText
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,305 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class EmojiSkinTonePicker: UIView {
|
||||
let emoji: Emoji
|
||||
let preferredSkinTonePermutation: [Emoji.SkinTone]?
|
||||
let completion: (EmojiWithSkinTones?) -> Void
|
||||
|
||||
private let referenceOverlay = UIView()
|
||||
private let containerView = UIView()
|
||||
|
||||
class func present(
|
||||
referenceView: UIView,
|
||||
emoji: EmojiWithSkinTones,
|
||||
completion: @escaping (EmojiWithSkinTones?) -> Void
|
||||
) -> EmojiSkinTonePicker? {
|
||||
guard emoji.baseEmoji.hasSkinTones else { return nil }
|
||||
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
|
||||
let picker = EmojiSkinTonePicker(emoji: emoji, completion: completion)
|
||||
|
||||
guard let superview = referenceView.superview else {
|
||||
owsFailDebug("reference is missing superview")
|
||||
return nil
|
||||
}
|
||||
|
||||
superview.addSubview(picker)
|
||||
|
||||
picker.referenceOverlay.autoMatch(.width, to: .width, of: referenceView)
|
||||
picker.referenceOverlay.autoMatch(.height, to: .height, of: referenceView, withOffset: 30)
|
||||
picker.referenceOverlay.autoPinEdge(.leading, to: .leading, of: referenceView)
|
||||
|
||||
let leadingConstraint = picker.autoPinEdge(toSuperviewEdge: .leading)
|
||||
|
||||
picker.layoutIfNeeded()
|
||||
|
||||
let halfWidth = picker.width() / 2
|
||||
let margin: CGFloat = 8
|
||||
|
||||
if (halfWidth + margin) > referenceView.center.x {
|
||||
leadingConstraint.constant = margin
|
||||
} else if (halfWidth + margin) > (superview.width() - referenceView.center.x) {
|
||||
leadingConstraint.constant = superview.width() - picker.width() - margin
|
||||
} else {
|
||||
leadingConstraint.constant = referenceView.center.x - halfWidth
|
||||
}
|
||||
|
||||
let distanceFromTop = referenceView.frame.minY - superview.bounds.minY
|
||||
if distanceFromTop > picker.containerView.height() {
|
||||
picker.containerView.autoPinEdge(toSuperviewEdge: .top)
|
||||
picker.referenceOverlay.autoPinEdge(.top, to: .bottom, of: picker.containerView, withOffset: -20)
|
||||
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
picker.autoPinEdge(.bottom, to: .bottom, of: referenceView)
|
||||
} else {
|
||||
picker.containerView.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
picker.referenceOverlay.autoPinEdge(.bottom, to: .top, of: picker.containerView, withOffset: 20)
|
||||
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .top)
|
||||
picker.autoPinEdge(.top, to: .top, of: referenceView)
|
||||
}
|
||||
|
||||
picker.alpha = 0
|
||||
UIView.animate(withDuration: 0.12) { picker.alpha = 1 }
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }) { _ in
|
||||
self.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
func didChangeLongPress(_ sender: UILongPressGestureRecognizer) {
|
||||
guard let singleSelectionButtons = singleSelectionButtons else { return }
|
||||
|
||||
if referenceOverlay.frame.contains(sender.location(in: self)) {
|
||||
singleSelectionButtons.forEach { $0.isSelected = false }
|
||||
} else {
|
||||
let point = sender.location(in: containerView)
|
||||
let previouslySelectedButton = singleSelectionButtons.first { $0.isSelected }
|
||||
singleSelectionButtons.forEach { $0.isSelected = $0.frame.insetBy(dx: -3, dy: -80).contains(point) }
|
||||
let selectedButton = singleSelectionButtons.first { $0.isSelected }
|
||||
|
||||
if let selectedButton = selectedButton, selectedButton != previouslySelectedButton {
|
||||
SelectionHapticFeedback().selectionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didEndLongPress(_ sender: UILongPressGestureRecognizer) {
|
||||
guard let singleSelectionButtons = singleSelectionButtons else { return }
|
||||
|
||||
let point = sender.location(in: containerView)
|
||||
if referenceOverlay.frame.contains(sender.location(in: self)) {
|
||||
// Do nothing.
|
||||
} else if let selectedButton = singleSelectionButtons.first(where: {
|
||||
$0.frame.insetBy(dx: -3, dy: -80).contains(point)
|
||||
}) {
|
||||
selectedButton.sendActions(for: .touchUpInside)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
init(emoji: EmojiWithSkinTones, completion: @escaping (EmojiWithSkinTones?) -> Void) {
|
||||
owsAssertDebug(emoji.baseEmoji.hasSkinTones)
|
||||
|
||||
self.emoji = emoji.baseEmoji
|
||||
self.preferredSkinTonePermutation = emoji.skinTones
|
||||
self.completion = completion
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
layer.shadowOffset = .zero
|
||||
layer.shadowOpacity = 0.25
|
||||
layer.shadowRadius = 4
|
||||
|
||||
referenceOverlay.backgroundColor = isDarkMode ? .ows_gray75 : .ows_white
|
||||
referenceOverlay.layer.cornerRadius = 9
|
||||
addSubview(referenceOverlay)
|
||||
|
||||
containerView.layoutMargins = UIEdgeInsets(top: 9, leading: 16, bottom: 9, trailing: 16)
|
||||
containerView.backgroundColor = isDarkMode ? .ows_gray75 : .ows_white
|
||||
containerView.layer.cornerRadius = 11
|
||||
addSubview(containerView)
|
||||
containerView.autoPinWidthToSuperview()
|
||||
containerView.setCompressionResistanceHigh()
|
||||
|
||||
if emoji.baseEmoji.allowsMultipleSkinTones {
|
||||
prepareForMultipleSkinTones()
|
||||
} else {
|
||||
prepareForSingleSkinTone()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Single Skin Tone
|
||||
|
||||
private lazy var yellowEmoji = EmojiWithSkinTones(baseEmoji: emoji, skinTones: nil)
|
||||
private lazy var yellowButton = button(for: yellowEmoji) { [weak self] emojiWithSkinTone in
|
||||
self?.completion(emojiWithSkinTone)
|
||||
}
|
||||
|
||||
private var singleSelectionButtons: [UIButton]?
|
||||
private func prepareForSingleSkinTone() {
|
||||
let hStack = UIStackView()
|
||||
hStack.axis = .horizontal
|
||||
hStack.spacing = 8
|
||||
containerView.addSubview(hStack)
|
||||
hStack.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
hStack.addArrangedSubview(yellowButton)
|
||||
|
||||
hStack.addArrangedSubview(.spacer(withWidth: 2))
|
||||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.width, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
hStack.addArrangedSubview(divider)
|
||||
|
||||
hStack.addArrangedSubview(.spacer(withWidth: 2))
|
||||
|
||||
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
|
||||
self?.completion(emojiWithSkinTone)
|
||||
}
|
||||
|
||||
singleSelectionButtons = skinToneButtons.map { $0.button }
|
||||
singleSelectionButtons?.forEach { hStack.addArrangedSubview($0) }
|
||||
singleSelectionButtons?.append(yellowButton)
|
||||
}
|
||||
|
||||
// MARK: - Multiple Skin Tones
|
||||
|
||||
private lazy var skinToneComponentEmoji: [Emoji] = {
|
||||
guard let skinToneComponentEmoji = emoji.skinToneComponentEmoji else {
|
||||
owsFailDebug("missing skin tone component emoji \(emoji)")
|
||||
return []
|
||||
}
|
||||
return skinToneComponentEmoji
|
||||
}()
|
||||
|
||||
private var buttonsPerComponentEmojiIndex = [Int: [(Emoji.SkinTone, UIButton)]]()
|
||||
private lazy var skinToneButton = button(for: EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: .init(repeating: .medium, count: skinToneComponentEmoji.count)
|
||||
)) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
guard self.selectedSkinTones.count == self.skinToneComponentEmoji.count else { return }
|
||||
self.completion(EmojiWithSkinTones(baseEmoji: self.emoji, skinTones: self.selectedSkinTones))
|
||||
}
|
||||
|
||||
private var selectedSkinTones = [Emoji.SkinTone]() {
|
||||
didSet {
|
||||
if selectedSkinTones.count == skinToneComponentEmoji.count {
|
||||
skinToneButton.setTitle(
|
||||
EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: selectedSkinTones
|
||||
).rawValue,
|
||||
for: .normal
|
||||
)
|
||||
skinToneButton.isEnabled = true
|
||||
skinToneButton.alpha = 1
|
||||
} else {
|
||||
skinToneButton.setTitle(
|
||||
EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: [.medium]
|
||||
).rawValue,
|
||||
for: .normal
|
||||
)
|
||||
skinToneButton.isEnabled = false
|
||||
skinToneButton.alpha = 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var skinTonePerComponentEmojiIndex = [Int: Emoji.SkinTone]() {
|
||||
didSet {
|
||||
var selectedSkinTones = [Emoji.SkinTone]()
|
||||
for idx in skinToneComponentEmoji.indices {
|
||||
for (skinTone, button) in buttonsPerComponentEmojiIndex[idx] ?? [] {
|
||||
if skinTonePerComponentEmojiIndex[idx] == skinTone {
|
||||
selectedSkinTones.append(skinTone)
|
||||
button.isSelected = true
|
||||
} else {
|
||||
button.isSelected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selectedSkinTones = selectedSkinTones
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareForMultipleSkinTones() {
|
||||
let vStack = UIStackView()
|
||||
vStack.axis = .vertical
|
||||
vStack.spacing = 6
|
||||
containerView.addSubview(vStack)
|
||||
vStack.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
for (idx, emoji) in skinToneComponentEmoji.enumerated() {
|
||||
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
|
||||
self?.skinTonePerComponentEmojiIndex[idx] = emojiWithSkinTone.skinTones?.first
|
||||
}
|
||||
buttonsPerComponentEmojiIndex[idx] = skinToneButtons
|
||||
|
||||
let hStack = UIStackView(arrangedSubviews: skinToneButtons.map { $0.button })
|
||||
hStack.axis = .horizontal
|
||||
hStack.spacing = 6
|
||||
vStack.addArrangedSubview(hStack)
|
||||
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
|
||||
|
||||
// If there's only one preferred skin tone, all the component emoji use it.
|
||||
if preferredSkinTonePermutation?.count == 1 {
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?.first
|
||||
} else {
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
|
||||
}
|
||||
}
|
||||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.height, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
vStack.addArrangedSubview(divider)
|
||||
|
||||
let leftSpacer = UIView.hStretchingSpacer()
|
||||
let middleSpacer = UIView.hStretchingSpacer()
|
||||
let rightSpacer = UIView.hStretchingSpacer()
|
||||
|
||||
let hStack = UIStackView(arrangedSubviews: [leftSpacer, yellowButton, middleSpacer, skinToneButton, rightSpacer])
|
||||
hStack.axis = .horizontal
|
||||
vStack.addArrangedSubview(hStack)
|
||||
|
||||
leftSpacer.autoMatch(.width, to: .width, of: rightSpacer)
|
||||
middleSpacer.autoMatch(.width, to: .width, of: rightSpacer)
|
||||
}
|
||||
|
||||
// MARK: - Button Helpers
|
||||
|
||||
func skinToneButtons(for emoji: Emoji, handler: @escaping (EmojiWithSkinTones) -> Void) -> [(skinTone: Emoji.SkinTone, button: UIButton)] {
|
||||
var buttons = [(Emoji.SkinTone, UIButton)]()
|
||||
for skinTone in Emoji.SkinTone.allCases {
|
||||
let emojiWithSkinTone = EmojiWithSkinTones(baseEmoji: emoji, skinTones: [skinTone])
|
||||
buttons.append((skinTone, button(for: emojiWithSkinTone, handler: handler)))
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
func button(for emoji: EmojiWithSkinTones, handler: @escaping (EmojiWithSkinTones) -> Void) -> UIButton {
|
||||
let button = OWSButton { handler(emoji) }
|
||||
button.titleLabel?.font = .boldSystemFont(ofSize: 32)
|
||||
button.setTitle(emoji.rawValue, for: .normal)
|
||||
button.setBackgroundImage(UIImage(color: isDarkMode ? .ows_gray60 : .ows_gray25), for: .selected)
|
||||
button.layer.cornerRadius = 6
|
||||
button.clipsToBounds = true
|
||||
button.autoSetDimensions(to: CGSize(width: 38, height: 38))
|
||||
return button
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue