feat: restore content warning input with black-yellow strip edges

This commit is contained in:
CMK 2022-10-28 19:06:18 +08:00
parent b12825a96a
commit 3100c59a3b
8 changed files with 284 additions and 24 deletions

View File

@ -126,6 +126,15 @@
"version" : "5.12.5" "version" : "5.12.5"
} }
}, },
{
"identity" : "stripes",
"kind" : "remoteSourceControl",
"location" : "https://github.com/eneko/Stripes.git",
"state" : {
"revision" : "d533fd44b8043a3abbf523e733599173d6f98c11",
"version" : "0.2.0"
}
},
{ {
"identity" : "swift-collections", "identity" : "swift-collections",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -48,6 +48,7 @@ let package = Package(
.package(url: "https://github.com/vtourraine/ThirdPartyMailer.git", from: "2.1.0"), .package(url: "https://github.com/vtourraine/ThirdPartyMailer.git", from: "2.1.0"),
.package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"), .package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"),
.package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"), .package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"),
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -122,6 +123,7 @@ let package = Package(
.product(name: "MetaTextKit", package: "MetaTextKit"), .product(name: "MetaTextKit", package: "MetaTextKit"),
.product(name: "CropViewController", package: "TOCropViewController"), .product(name: "CropViewController", package: "TOCropViewController"),
.product(name: "PanModal", package: "PanModal"), .product(name: "PanModal", package: "PanModal"),
.product(name: "Stripes", package: "Stripes"),
] ]
), ),
.testTarget( .testTarget(

View File

@ -260,6 +260,9 @@ extension ComposeContentViewController {
viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive) viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive)
viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive) viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive)
viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive) viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive)
viewModel.$maxTextInputLimit.assign(to: &composeContentToolbarViewModel.$maxTextInputLimit)
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength)
} }
} }
@ -385,6 +388,16 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate {
self.viewModel.isEmojiActive.toggle() self.viewModel.isEmojiActive.toggle()
case .contentWarning: case .contentWarning:
self.viewModel.isContentWarningActive.toggle() self.viewModel.isContentWarningActive.toggle()
if self.viewModel.isContentWarningActive {
Task { @MainActor in
try? await Task.sleep(nanoseconds: .second / 20) // 0.05s
self.viewModel.setContentWarningTextViewFirstResponderIfNeeds()
} // end Task
} else {
if self.viewModel.contentWarningMetaText?.textView.isFirstResponder == true {
self.viewModel.setContentTextViewFirstResponderIfNeeds()
}
}
case .visibility: case .visibility:
assertionFailure() assertionFailure()
} }

View File

@ -0,0 +1,57 @@
//
// ComposeContentViewModel+MetaTextDelegate.swift
//
//
// Created by MainasuK on 2022/10/28.
//
import os.log
import UIKit
import MetaTextKit
import TwitterMeta
import MastodonMeta
// MARK: - MetaTextDelegate
extension ComposeContentViewModel: MetaTextDelegate {
public enum MetaTextViewKind: Int {
case none
case content
case contentWarning
}
public func metaText(
_ metaText: MetaText,
processEditing textStorage: MetaTextStorage
) -> MetaContent? {
let kind = MetaTextViewKind(rawValue: metaText.textView.tag) ?? .none
switch kind {
case .none:
assertionFailure()
return nil
case .content:
let textInput = textStorage.string
self.content = textInput
let content = MastodonContent(
content: textInput,
emojis: [:] // TODO: emojiViewModel?.emojis.asDictionary ?? [:]
)
let metaContent = MastodonMetaContent.convert(text: content)
return metaContent
case .contentWarning:
let textInput = textStorage.string.replacingOccurrences(of: "\n", with: " ")
self.contentWarning = textInput
let content = MastodonContent(
content: textInput,
emojis: [:] // emojiViewModel?.emojis.asDictionary ?? [:]
)
let metaContent = MastodonMetaContent.convert(text: content)
return metaContent
}
}
}

View File

@ -6,12 +6,13 @@
// //
import os.log import os.log
import Foundation import UIKit
import Combine import Combine
import CoreDataStack import CoreDataStack
import MastodonCore import MastodonCore
import Meta import Meta
import MastodonMeta import MastodonMeta
import MetaTextKit
public final class ComposeContentViewModel: NSObject, ObservableObject { public final class ComposeContentViewModel: NSObject, ObservableObject {
@ -30,6 +31,42 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
@Published var viewLayoutFrame = ViewLayoutFrame() @Published var viewLayoutFrame = ViewLayoutFrame()
@Published var authContext: AuthContext @Published var authContext: AuthContext
// output
// limit
@Published public var maxTextInputLimit = 500
// content
public weak var contentMetaText: MetaText? {
didSet {
// guard let textView = contentMetaText?.textView else { return }
// customEmojiPickerInputViewModel.configure(textInput: textView)
}
}
@Published public var initialContent = ""
@Published public var content = ""
@Published public var contentWeightedLength = 0
@Published public var isContentEmpty = true
@Published public var isContentValid = true
@Published public var isContentEditing = false
// content warning
weak var contentWarningMetaText: MetaText? {
didSet {
//guard let textView = contentWarningMetaText?.textView else { return }
//customEmojiPickerInputViewModel.configure(textInput: textView)
}
}
@Published public var isContentWarningActive = false
@Published public var contentWarning = ""
@Published public var contentWarningWeightedLength = 0 // set 0 when not composing
@Published public var isContentWarningEditing = false
// author
@Published var avatarURL: URL?
@Published var name: MetaContent = PlaintextMetaContent(string: "")
@Published var username: String = ""
// poll // poll
@Published var isPollActive = false @Published var isPollActive = false
@Published public var pollOptions: [PollComposeItem.Option] = { @Published public var pollOptions: [PollComposeItem.Option] = {
@ -42,23 +79,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
@Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay @Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay
@Published public var maxPollOptionLimit = 4 @Published public var maxPollOptionLimit = 4
// emoji
@Published var isEmojiActive = false @Published var isEmojiActive = false
@Published var isContentWarningActive = false
// output
// content
@Published public var initialContent = ""
@Published public var content = ""
@Published public var contentWeightedLength = 0
@Published public var isContentEmpty = true
@Published public var isContentValid = true
@Published public var isContentEditing = false
// author
@Published var avatarURL: URL?
@Published var name: MetaContent = PlaintextMetaContent(string: "")
@Published var username: String = ""
// UI & UX // UI & UX
@Published var replyToCellFrame: CGRect = .zero @Published var replyToCellFrame: CGRect = .zero
@ -87,6 +109,26 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
self.username = user.acctWithDomain self.username = user.acctWithDomain
} }
.store(in: &disposeBag) .store(in: &disposeBag)
// bind text
$content
.map { $0.count }
.assign(to: &$contentWeightedLength)
Publishers.CombineLatest(
$contentWarning,
$isContentWarningActive
)
.map { $1 ? $0.count : 0 }
.assign(to: &$contentWarningWeightedLength)
Publishers.CombineLatest3(
$contentWeightedLength,
$contentWarningWeightedLength,
$maxTextInputLimit
)
.map { $0 + $1 <= $2 }
.assign(to: &$isContentValid)
} }
deinit { deinit {
@ -120,6 +162,72 @@ extension ComposeContentViewModel {
} }
} }
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = true
case contentWarningMetaText?.textView:
isContentWarningEditing = true
default:
break
}
}
public func textViewDidEndEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = false
case contentWarningMetaText?.textView:
isContentWarningEditing = false
default:
break
}
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case contentMetaText?.textView:
return true
case contentWarningMetaText?.textView:
let isReturn = text == "\n"
if isReturn {
setContentTextViewFirstResponderIfNeeds()
}
return !isReturn
default:
assertionFailure()
return true
}
}
func insertContentText(text: String) {
guard let contentMetaText = self.contentMetaText else { return }
// FIXME: smart prefix and suffix
let string = contentMetaText.textStorage.string
let isEmpty = string.isEmpty
let hasPrefix = string.hasPrefix(" ")
if hasPrefix || isEmpty {
contentMetaText.textView.insertText(text)
} else {
contentMetaText.textView.insertText(" " + text)
}
}
func setContentTextViewFirstResponderIfNeeds() {
guard let contentMetaText = self.contentMetaText else { return }
guard !contentMetaText.textView.isFirstResponder else { return }
contentMetaText.textView.becomeFirstResponder()
}
func setContentWarningTextViewFirstResponderIfNeeds() {
guard let contentWarningMetaText = self.contentWarningMetaText else { return }
guard !contentWarningMetaText.textView.isFirstResponder else { return }
contentWarningMetaText.textView.becomeFirstResponder()
}
}
// MARK: - DeleteBackwardResponseTextFieldRelayDelegate // MARK: - DeleteBackwardResponseTextFieldRelayDelegate
extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate { extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate {

View File

@ -27,6 +27,10 @@ extension ComposeContentToolbarView {
@Published var isEmojiActive = false @Published var isEmojiActive = false
@Published var isContentWarningActive = false @Published var isContentWarningActive = false
@Published public var maxTextInputLimit = 500
@Published public var contentWeightedLength = 0
@Published public var contentWarningWeightedLength = 0
// output // output
init(delegate: ComposeContentToolbarViewDelegate) { init(delegate: ComposeContentToolbarViewDelegate) {

View File

@ -74,8 +74,18 @@ struct ComposeContentToolbarView: View {
} }
} }
Spacer() Spacer()
Text("Hello") let count: Int = {
.font(.system(size: 16, weight: .regular)) if viewModel.isContentWarningActive {
return viewModel.contentWeightedLength + viewModel.contentWarningWeightedLength
} else {
return viewModel.contentWeightedLength
}
}()
let remains = viewModel.maxTextInputLimit - count
let isOverflow = remains < 0
Text("\(remains)")
.foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel))
.font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular))
} }
.padding(.leading, 4) // 4 + 12 = 16 .padding(.leading, 4) // 4 + 12 = 16
.padding(.trailing, 16) .padding(.trailing, 16)

View File

@ -7,8 +7,10 @@
import os.log import os.log
import SwiftUI import SwiftUI
import MastodonAsset
import MastodonCore import MastodonCore
import MastodonLocalization import MastodonLocalization
import Stripes
public struct ComposeContentView: View { public struct ComposeContentView: View {
@ -22,14 +24,69 @@ public struct ComposeContentView: View {
public var body: some View { public var body: some View {
VStack(spacing: .zero) { VStack(spacing: .zero) {
Group { Group {
// content warning
if viewModel.isContentWarningActive {
MetaTextViewRepresentable(
string: $viewModel.contentWarning,
width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2,
configurationHandler: { metaText in
viewModel.contentWarningMetaText = metaText
metaText.textView.attributedPlaceholder = {
var attributes = metaText.textAttributes
attributes[.foregroundColor] = UIColor.secondaryLabel
return NSAttributedString(
string: L10n.Scene.Compose.contentInputPlaceholder,
attributes: attributes
)
}()
metaText.textView.returnKeyType = .next
metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.contentWarning.rawValue
metaText.textView.delegate = viewModel
metaText.delegate = viewModel
}
)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, ComposeContentView.margin)
.background(
Color(UIColor.systemBackground)
.overlay(
HStack {
Stripes(config: StripesConfig(
background: Color.yellow,
foreground: Color.black,
degrees: 45,
barWidth: 2.5,
barSpacing: 3.5
))
.frame(width: ComposeContentView.margin * 0.5)
.frame(maxHeight: .infinity)
.id(UUID())
Spacer()
Stripes(config: StripesConfig(
background: Color.yellow,
foreground: Color.black,
degrees: 45,
barWidth: 2.5,
barSpacing: 3.5
))
.frame(width: ComposeContentView.margin * 0.5)
.frame(maxHeight: .infinity)
.scaleEffect(x: -1, y: 1, anchor: .center)
.id(UUID())
}
)
)
} // end if viewModel.isContentWarningActive
// author // author
authorView authorView
.padding(.top, 14) .padding(.top, 14)
.padding(.horizontal, ComposeContentView.margin)
// content editor // content editor
MetaTextViewRepresentable( MetaTextViewRepresentable(
string: $viewModel.content, string: $viewModel.content,
width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2, width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2,
configurationHandler: { metaText in configurationHandler: { metaText in
viewModel.contentMetaText = metaText
metaText.textView.attributedPlaceholder = { metaText.textView.attributedPlaceholder = {
var attributes = metaText.textAttributes var attributes = metaText.textAttributes
attributes[.foregroundColor] = UIColor.secondaryLabel attributes[.foregroundColor] = UIColor.secondaryLabel
@ -39,16 +96,18 @@ public struct ComposeContentView: View {
) )
}() }()
metaText.textView.keyboardType = .twitter metaText.textView.keyboardType = .twitter
// metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue
// metaText.textView.delegate = viewModel metaText.textView.delegate = viewModel
// metaText.delegate = viewModel metaText.delegate = viewModel
metaText.textView.becomeFirstResponder() metaText.textView.becomeFirstResponder()
} }
) )
.frame(minHeight: 100) .frame(minHeight: 100)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, ComposeContentView.margin)
// poll // poll
pollView pollView
.padding(.horizontal, ComposeContentView.margin)
} }
.background( .background(
GeometryReader { proxy in GeometryReader { proxy in
@ -65,8 +124,6 @@ public struct ComposeContentView: View {
) )
Spacer() Spacer()
} // end VStack } // end VStack
.padding(.horizontal, ComposeContentView.margin)
// .frame(alignment: .top)
} // end body } // end body
} }