心安らぐ重すぎチャットアプリ(物理)


[クソアプリ Advent Calendar 2022 3 15日目](https://qiita.com/advent-calendar/2022/kuso-app)を担当させていただきます😎

## 🏋はじめに
皆さんは｢重すぎる言葉｣を言われたことはありますか？


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431953/0091be40-74e8-0f37-3710-65759360d5a7.png)

あいにく私は言われたことがないので、その心情があまりわからないのですが、友人が体験していたのをまじかに見て｢重すぎる言葉｣を受け止めるのは 大変そうだなあ と感じています

そこで、重すぎる言葉には *物理的な重さ* を持たせてしまいましょう。

百聞は一見にしかず、まずはご覧下さい。   

## 🏋重すぎチャット

![omosugi001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431953/20f9a989-c4b0-2c05-2f07-5182e7118a6c.gif)
![スクリーンショット 2022-12-14 23.09.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431953/9ea3a971-6c9d-0094-7840-98a2344ef81b.png)


ご覧の通り，このチャットアプリもどきはいわゆる「重そうな言葉」を含んでいると会話の途中であっても，強制的に重力加速度が加わり，会話内容が自由落下します．

これによって，なんぼでも重い言葉を言われようが，重い言葉は重いので落下し，**会話を回避すること & ネタに昇華すること** ができるようという素晴らしいチャットアプリです．

一度重い発言があれば，それまでの会話内容を水に流すために全て落下するようにしました．

重さのコントロールは重力加速度を変更することで行なっています．　
つまり，比較的軽い発言は _ふわふわ_ と落ちます．

### 🏋加速度付与のルール
| 条件 | 加速度 9.8 の倍率 |
|-----|-------------------|
|重すぎる言葉集JSONファイルに定義される文言が含まれるとき | + 1.0倍 |
|一文字毎の加算 | + 0.0001倍 | 
|200文字を超える場合 | + 0.1倍 | 

### 🏋返答の内容
JSONファイルに定義されている定義されている文言をランダムで返します

## 🏋環境
このアプリはiOS向けです． 
Xcode 14.1を使用しました. 
特にこれといって変わったことはありませんね．

iOSフレームワークにはいつものUIKit/コードベース(StoryBoardは使用しない)で開発しました．

## 🏋技術的なお話(の前に)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431953/f80da8f9-185f-b6d6-c5b7-428e1aec8523.png)

「なんだこれはチャットアプリじゃないではないか」と思われている方も多いかと思います．

JSONファイルの内容をランダムで投げかける「心」を無くしたJSON投げマシーンアプリになってしまいました。
すみませんでした。

## 🏋(技術的なお話？) ソースコードについて
### 🏋チャットアプリ画面
チャットアプリ画面は`ChatViewController.swift`にまとめています． `UIKit`には落下アニメーションが組み込みで存在するのでただ単に`UIDynamicAnimator`と`UIGravityBehavior`を組み合わせるだけで実現可能です．

`UICollisionBehavior`を用いれば， 衝突アニメーションもつけることができます．　
この辺はお好みですかね？

チャット画面の会話バルーン(文字を表示する部分)には`AutoLayout`は使用しておらず， `CGRect`で大きさを計算して描画しています．

以下，ソースコードの一部です．
```swift:ChatScrollViewController.swift
import UIKit

class ChatScrollViewController: UIViewController, ChatInputViewDelegate, ChatManageDelegate{
 // 略
    var dropViews: [ChatBalloon] = []
    var dynamicAnimator: UIDynamicAnimator!
    let gBehavior = UIGravityBehavior()
 // 略
    func addMessageDropView(dropMag: Float, message: String, isMe: Bool, isCollision: Bool = false){
        let dView = ChatBalloon(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
        self.scrollView.addSubview(dView)
        dView.setIsMe(isMe: isMe)
       
        dView.text = message
        dView.numberOfLines = 0;
        
        let size       = dView.sizeThatFits(CGSize(width: self.width * 0.8, height: height*100))
        let sizeForFit = dView.sizeThatFits(CGSize(width: self.width * 0.8, height: height*100))
        
        if(isMe){
            if size.width != sizeForFit.width{
                dView.frame = CGRectMake(width - size.width - offset,       mostBottomPosition, size.width,       size.height)
            }else{
                dView.frame = CGRectMake(width - sizeForFit.width - offset, mostBottomPosition, sizeForFit.width, sizeForFit.height)
            }
        }else{
            if size.width != sizeForFit.width{
                dView.frame = CGRectMake(offset, mostBottomPosition, size.width,       size.height)
            }else{
                dView.frame = CGRectMake(offset, mostBottomPosition, sizeForFit.width, sizeForFit.height)
            }
        }

        
        if(dropMag < 0.001){
            mostBottomPosition += size.height + offset
            self.chatViewHeightConstraint.isActive = false
            let h = (mostBottomPosition < self.height) ? self.height : self.height + mostBottomPosition
            self.chatViewHeightConstraint = self.chatView.heightAnchor.constraint(equalToConstant: h)
            self.chatViewHeightConstraint.isActive = true
           
            chatView.setNeedsLayout()
            
            scrollView.contentSize = chatView.frame.size
            scrollView.flashScrollIndicators()
            
            chatView.setNeedsDisplay()
        }else{
            mostBottomPosition = 0
        }
               
        dView.setNeedsDisplay()
        
        // 落下アニメーション
        gBehavior.addItem(dView)
        gBehavior.magnitude = CGFloat(dropMag)
        dynamicAnimator.addBehavior(gBehavior)
        
        // 衝突アニメーション        
        if(isCollision){
            let cBehavior = UICollisionBehavior(items: self.dropViews)
            cBehavior.addItem(dView)
            cBehavior.translatesReferenceBoundsIntoBoundary = true
            dynamicAnimator.addBehavior(cBehavior)
            
            self.dropViews.append(dView)
        }
    }
    // 略
}
```

当たり前ですが，キーボード部分にいては`keyboardLayoutGuide.topAnchor`への制約を使用してキーボードを閉じたり開いたりしても追従するようになっています． 
チャット表示画面の`UITapGestureRecognizer`を利用してキーボードを閉じています．

ちなみにこの方法だとナビゲーションバーが存在する時， この`ViewController`の支配下にないためキーボードが閉じないのでもう少しちゃんと作らないといけませんね．

```swift:ChatBalloonView.swift
import Foundation
import UIKit

class ChatBalloon: UILabel{
    public var padding: UIEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
    public var mePadding: UIEdgeInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 8)
    
    public var balloonBox: CGFloat = 13
    private var isMe: Bool = true
    private var backColor: UIColor = .systemGreen
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.font = .monospacedSystemFont(ofSize: 15.0, weight: .regular)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public func setIsMe(isMe: Bool){
        self.isMe = isMe
        self.backgroundColor = .clear
        if(self.isMe){
            self.backColor = .systemGreen
        }else{
            self.backColor = .white
        }
    }
    
    override func draw(_ rect: CGRect) {
        let h: CGFloat = rect.height
        let w: CGFloat = rect.width
                
        let offset: CGFloat = 1
        
        let arrowPath = UIBezierPath()
        self.backColor.setFill()
        self.backColor.setStroke()
        
        var points: [[CGFloat]] = [
            [w - balloonBox - offset, balloonBox + offset],
            [w  - offset, 0 + offset, w  - offset - offset, balloonBox/1.5 + offset],
            [w - balloonBox - offset, balloonBox/2.0 + offset, w - balloonBox / 2.0 - offset, balloonBox/2.0 + offset],
            [w - balloonBox - offset, balloonBox/2.0 + offset, w - balloonBox - offset, balloonBox/2.0 + offset],
            [0, 0, w - balloonBox + 2, h]
        ]
        
        if(!self.isMe){
            for i in 0..<points.count-1{
                for j in 0..<points[i].count{
                    if j % 2 == 0{
                        points[i][j] *= -1.0
                        points[i][j] += w
                    }
                }
            }
            points[4][0] += balloonBox / 2.0;
        }        
        
        arrowPath.move(to: CGPoint(x: points[0][0], y: points[0][1]))
        for i in 0..<3{
            let index = i + 1
            arrowPath.addQuadCurve(to: CGPoint(x: points[index][0], y: points[index][1]), controlPoint: CGPoint(x: points[index][2], y: points[index][3]))
        }
        
        arrowPath.close()
        arrowPath.fill()
        arrowPath.stroke()
        
        let contentPath = UIBezierPath(roundedRect: CGRect(x: points[4][0], y: points[4][1], width: points[4][2], height: points[4][3]), cornerRadius: 9.0)
        contentPath.stroke()
        contentPath.fill()
        
        super.draw(rect)
    }
    
    override func drawText(in rect: CGRect) {
        var insetPadding: UIEdgeInsets!
        if(self.isMe){
            insetPadding = self.padding
        }else{
            insetPadding = self.mePadding
        }
        
        super.drawText(in: rect.inset(by: insetPadding!))
    }
    
    override var intrinsicContentSize: CGSize {
        var retSize = super.intrinsicContentSize
        retSize.width  += padding.left
        retSize.height += padding.top  + padding.bottom
        
        if(self.isMe){
            retSize.width += padding.right * 2.5
        }else{
            retSize.width += padding.right * 2.0
        }
        
        return retSize
    }
    
    override func sizeThatFits(_ size: CGSize) -> CGSize {
        var retSize = super.sizeThatFits(size)
        retSize.width  += padding.left
        retSize.height += padding.top  + padding.bottom
        
        if(self.isMe){
            retSize.width += padding.right * 2.5
        }else{
            retSize.width += padding.right * 2.0
        }
        
        return retSize
    }
}
```

チャット部分は`UILabel`のサブクラスとして実装しました．　`draw`関数で必要な描画を行います．

データの受け取りなどは， `ChatSystemIntractor`へのDelegateで処理しています．

```swift:ChatSystemIntractor.swift
protocol ChatManageDelegate{
    func onReceiveChatMessageFromServer(text: String) // 未実装
    func onReceiveChatMessageTest(text: String)
}
```

## 🏋最後に🏋
いかがだったでしょうか？ 
これであなたもいつでもどこでも，会話を自由落下させられますね．

私としては，改良したい点がいくつか(未実装...)あります．

- 重すぎ文言判定に自然言語処理を組み込みたかった (重すぎ文言をword2vecでcos類似度を測るなど)
- もっとしっかりUIもサーバサイドも構築して，普通のチャットサービスとして遜色ないレベルまで上げたかった

もう少しクオリティを上げたかったです．コードも雑で...すみませんほんと．
来年は前日に作り始めるのでなく，しっかり余裕を持って作ります！
頑張るぞ〜〜〜
~~**LINEで働いている方！ぜひLINEアプリ本体への実装をお願いします!**~~

ありがとうございました．

# 🏋P.S.
衝突アニメーションアリだとこんな感じです
![omosugi002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431953/aa675a23-9675-292f-9c12-4d7d51f17dfc.gif)

明日は　@kamimi01 さんの記事です．！