iOS K線三方庫 - ZXKLine

簡品發表於2017-11-06

github

1.簡介篇

  • 蠟燭圖和山形圖繪製切換
  • 5種指標繪製切換
  • 長按蠟燭和指標線詳情展示
  • 觸底載入更多
  • 實時蠟燭繪製實現
  • 二級橫屏和蠟燭三級橫屏


  • 適配兩種佈局

2.原理篇

2.1 tableView作為畫布依耐

為什麼選擇了tableView

  • 嘗試是否能對繪製有candle的Cell進行復用;
  • 換個思維造輪子;

需要解決的問題:變縱向滾動為縱向滾動

  • 如圖所示:在旋轉時,是繞tableView中心進行旋轉的,為了使旋轉後的tableView的frame能夠和superView的大小一致,那麼就要使旋轉前的tableView偏移一定距離;
    .
    .
    self.tableView.transform = CGAffineTransformMakeRotation(-M_PI/2);
    .
    .
    [self.view addSubview:self.tableView];
    .
    .
    [self.tableView mas_updateConstraints:^(MASConstraintMaker *make) {
              make.left.mas_equalTo((width-height)/2);
           make.top.mas_equalTo(-(width-height)/2);
           make.width.mas_equalTo(height);
             make.height.mas_equalTo(width);
    }];  複製程式碼
  • 優缺點:雖然進行到後面,蠟燭全是用CAShapeLayer+UIBeizerPath繪製的,cell的複用並沒有起到多大的作用,並且旋轉之後涉及到了tableView的x,y座標在使用中的轉換(這點大家注意下),但是能感到慶幸的是:使用了cell之後,在計算蠟燭橫座標的時候就是cell.indexPath.row*rowHeight;再者就是在縮放的時候,可以直接修改cell的高度就可以達到縮放的目的;

2.2 縮放

縮放有度

- (void)pinchAction:(UIPinchGestureRecognizer *)sender
{ 
    static CGFloat oldScale = 1.0f;
       CGFloat difValue = sender.scale - oldScale;
    NSLog(@"difValue=====%f",difValue);
       NSLog(@"oldScale=====%f",oldScale);
       if (ABS(difValue)>StockChartScaleBound) {

    CGFloat oldKlineWidth = self.candleWidth;
    // NSLog(@"原來的index%ld",oldNeedDrawStartIndex);
    self.candleWidth = oldKlineWidth * ((difValue > 0) ? (1+StockChartScaleFactor):(1-StockChartScaleFactor));
    oldScale = sender.scale;
    if (self.candleWidth < scale_MinValue) {

        self.candleWidth = scale_MinValue;
    }else if (self.candleWidth > scale_MaxValue)
    {
        self.candleWidth = scale_MaxValue;
    }
  }
}複製程式碼
  • 在每次縮放的時候,進行判斷:
    1)只有觸發的縮放大於某個預訂值的時候才進行縮放
    2)控制每次縮放的比率;
    3)控制縮放的總體範圍;

定點縮放

//這句話達到讓tableview在縮放的時候能夠保持縮放中心點不變;
//實現原理:在放大縮小的時候,計算出變化後和變化前中心點的距離,然後為了保持中心點的偏移值始終保持不變,就直接在原來的偏移上加減變換的距離
//ceil(centerPoint.y/oldKlineWidth)中心點前面的cell個數
//self.rowHeight-oldKlineWidth每個cell的高度的變化
CGFloat pinchOffsetY  = ceil(centerPoint.y/oldKlineWidth)*(self.candleWidth-oldKlineWidth)+oldNeedDrawStartPointY;
if (pinchOffsetY<0) {

    pinchOffsetY = 0;
}
if (pinchOffsetY+self.subViewWidth>self.kLineModelArr.count*self.candleWidth) {

    pinchOffsetY = self.kLineModelArr.count*self.candleWidth - self.subViewWidth;
}

[self.tableView setContentOffset:CGPointMake(0, pinchOffsetY)];複製程式碼

2.3 實現原理

巨集觀佈局

兩個關鍵引數:

  • 螢幕中顯示的第一個蠟燭圖的X座標:

    NSUInteger leftArrCount = ABS(scrollViewOffsetX/self.candleWidth);
       _needDrawStartIndex = leftArrCount;      複製程式碼
  • 螢幕中能夠顯示的蠟燭個數:

     - (NSInteger)needDrawKlineCount
    {
        CGFloat width = self.subViewWidth;
        _needDrawKlineCount = ceil(width/self.candleWidth);
        return _needDrawKlineCount;
    }    複製程式碼

    根據這兩個引數,起點和長度,就可以從資料來源陣列中準確的取出當前螢幕顯示的蠟燭圖的資料;然後滑動過程中實時計算並進行座標轉換

座標相關換算

  • 極值:從當前螢幕顯示的資料來源陣列獲取的最大值和最小值
  • 單位價格所代表的畫素值

      self.heightPerPoint = self.candleChartHeight/(self.maxAssert-self.minAssert);  複製程式碼
  • 開收高低值從價格轉換成畫素值

蠟燭繪製

CAShapeLayer+UIBeizerPath

2.4 Socket資料結算

詳見ZXSocketDataReformer
針對伺服器返回的資料格式:@"時間戳,實時價格";我們需要利用這一個個的資料自己構建蠟燭模型;

  • 第一模型構建:假如一分鐘返回80個資料, 那麼我們需要判斷這一分鐘開始的時候,並且取出這一分鐘的第一個資料First,構建一個全新的模型A;模型A的開.收.高.低價都是第一資料的實時價格;
  • 模型替換:第一個模型構建之後,新的資料Second到來,那麼我們比較得出高值和低值替換模型A的高低值,並且此時模型A的收盤價為資料Second的實時價格;
  • 模型結算(重點):
    結算:就是對個M1\M5\M15..中返回的所有資料自己結算出一個蠟燭模型,也就是四個值:開\收\高\低;
    結算的事件點判斷方式:
    1)以socket返回資料的時間戳結算:這樣結算在資料上不會有什麼誤差,但是時間上會有誤差; eg:針對M1而言,假如在6'58''的時候返回此分蠟燭的最後一個值,如果用socket的時間作為結算的話,那麼我們必須等到下一個socket返回值的時間戳到來才能結算,假如socket在7'00''-7'01''之間返回了資料的話,很好,我們可以直接結算上一個蠟燭,並且及時的建立一個新的蠟燭模型;但是資料並不是每次都會變化如此頻繁,如果下一個資料的到來是7'16'';那麼中間這18'',k線圖會靜止18'',那麼相當於6'的那個蠟燭會延遲16''進行推進,便造成了時間上的誤差;並且當資料漲停或者停牌的時候,socket資料沒有變動,便不會返回資料,那麼這個時間k線圖也是不會有任何動作;
    2)以請求伺服器時間戳結算:會導致資料上的誤差;eg:在7'00''需要結算,但是這個時間socket在7'00''的時候返回了多個資料,但是結算的時候只會取到其中一個資料作為6'的收盤價,其他資料將遺留到下個蠟燭;
    解決:
    1)以socket和伺服器的時間戳相結合的方式進行結算:我在ZXSocketDataReformer中也是這麼做的,第一次請求伺服器時間,然後本地安裝定時器進行伺服器時間同步; 由socket時間戳進行模型構造,到了整點,優先socket進行模型推進,如果整點的時候沒有socket返回,就由伺服器時間進行推進;
    2)定時器由伺服器建立,最好就是在整點延遲1秒的時候,如果在00''-01''的時候已經有socket資料傳送到移動端的話,那麼就不需要推送假資料,如果沒有socket資料產生,就推送一個假資料到移動端,告訴移動端,資料需要進行結算,移動端只需要用socket進行結算; (好吧,自己都繞暈了,如果要求不是那麼高其實僅僅按照socket進行資料結算也夠用了);

2.5 實時繪製

考慮如下情況:

程式碼大概是這樣的 :

- (void)handleNewestCellWhenScrollToBottomWithNewKlineModel:(KlineModel *)klineModel

{

        //==0的時候需要插入一個新的cell;否則只需要重新整理最後一個cell
    if (self.isNew) {

        KlineModel *newsDataModel =  [self calulatePositionWithKlineModel:klineModel];
        [self.kLineModelArr addObject:newsDataModel];

        double oldMax = self.maxAssert;
        double oldMin = self.minAssert;


        [self calculateNeedDrawKlineArr];
        [self calculateMaxAndMinValueWithNeedDrawArr:self.needDrawKlineArr];

        //不等的話就重繪
        if (oldMax<self.maxAssert||oldMin>self.minAssert) {


            dispatch_async(dispatch_get_main_queue(), ^{

                [self.tableView setContentOffset:CGPointMake(0, (self.kLineModelArr.count-self.needDrawKlineCount)*self.candleWidth+(self.needDrawKlineCount*self.candleWidth-self.subViewWidth))];
            });

            [self drawTopKline];

        }else{
            //否則就插入
            NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.kLineModelArr.count-1 inSection:0];
            dispatch_async(dispatch_get_main_queue(), ^{

                //先增加  再偏移
                [self.tableView beginUpdates];
                [self.tableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
                [self.tableView endUpdates];
                [self.tableView setContentOffset:CGPointMake(0, (self.kLineModelArr.count-self.needDrawKlineCount)*self.candleWidth+(self.needDrawKlineCount*self.candleWidth-self.subViewWidth))];
            });

            [self delegateToReturnKlieArr];
        }

    }else{


        KlineModel *newsDataModel =  [self calulatePositionWithKlineModel:klineModel];
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.kLineModelArr.count-1 inSection:0];

        [self.kLineModelArr replaceObjectAtIndex:self.kLineModelArr.count-1 withObject:newsDataModel];


        CGFloat oldMax = self.maxAssert;
        CGFloat oldMin = self.minAssert;


        [self calculateNeedDrawKlineArr];
        [self calculateMaxAndMinValueWithNeedDrawArr:self.needDrawKlineArr];
        //如果計算出來的最新的極值不在上一次計算的極值直接的話就重繪,否則就重新整理最後一個即可
        if (oldMax<self.maxAssert||oldMin>self.minAssert) {

            [self drawTopKline];

        }else{

            dispatch_async(dispatch_get_main_queue(), ^{

                [self.tableView beginUpdates];
                [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
                [self.tableView endUpdates];
                [self delegateToReturnKlieArr];
            });

        }

    }

}複製程式碼

實際使用過程中在insert或者reloadrows的時候,偶爾會出現崩潰,暫時還沒解決,索性改為了直接重繪全屏了(我內心也是拒絕的),若是你們也不甘心讓它直接重繪,可到--ZXMainView.m--- (void)handleNewestCellWhenScrollToBottomWithNewKlineModel:(KlineModel *)klineModel;開啟註釋的方法,終結了它;

3.使用篇

3.1 基本使用

  • 基本的k線圖的接入可以在demo中SecondStepViewController中看到,執行需在appDelegate中切換rootViewController;
  • JoinUpSocketViewController是接入socket實時繪製的demo,為了脫敏,控制器中的socket資料是隨機產生的;
  • 具體的接入程式碼或者介面都可以在demo中看到,這裡不做過多描述;

3.2 使用注意

3.2.1 歷史資料轉模型

(詳見Reformer---ZXCandleDataReformer)
本地歷史資料格式為:

/*
 @[@"時間戳,收盤價,開盤價,最高價,最低價,成交量",
 @"時間戳,收盤價,開盤價,最高價,最低價,成交量",
 @"時間戳,收盤價,開盤價,最高價,最低價,成交量",
 @"...",
 @"..."];
 */  複製程式碼

相應的模型轉換格式為:

- (NSArray<KlineModel *>*)transformDataWithDataArr:(NSArray *)dataArr currentRequestType:(NSString *)currentRequestType
{
    self.currentRequestType = currentRequestType;
    //修改資料格式  →  ↓↓↓↓↓↓↓終點到啦↓↓↓↓↓↓↓↓↓  ←
    NSMutableArray *tempArr = [NSMutableArray array];
    __weak typeof(self) weakSelf = self;
    [dataArr enumerateObjectsUsingBlock:^(NSString *dataStr, NSUInteger idx, BOOL * _Nonnull stop) {

        NSArray *strArr = [dataStr componentsSeparatedByString:@","];
        KlineModel *model = [KlineModel new];
        model.timestamp  = [strArr[0] integerValue];
        model.timeStr = [weakSelf setTime:strArr[0]];
        model.closePrice = [strArr[1] doubleValue];
        model.openPrice = [strArr[2] doubleValue];
        model.highestPrice = [strArr[3] doubleValue];
        model.lowestPrice = [strArr[4] doubleValue];
        if (strArr.count>=6) {

            model.volumn = @([strArr[5] doubleValue]);
        }else{
            model.volumn = @(0);
        }

        model.x = idx;
        [tempArr addObject:model];
        model = nil;
    }];
    return tempArr;
}複製程式碼

歷史資料模型轉換需要使用者根據請求歷史資料的實際格式進行轉換;

3.2.2 Socket資料轉模型

(詳見ZXSocketDataReformer)
在socket結算的時候,若需要伺服器時間結合socket返回的時間共同完成一個蠟燭的時候,這裡需要改為獲取伺服器時間;

- (void)requestServiceTime:(void(^)(NSInteger timesamp))success
{

        //這裡Demo使用的本地時間代替;正確的應該取下面的伺服器時間
        NSDate *date = [NSDate dateWithTimeIntervalSinceNow:0];
        NSTimeInterval timestamp = [date timeIntervalSince1970];
        success(timestamp);

        //獲取伺服器時間
    //    NSString *urlStr = @"伺服器時間校對地址";
    //
    //    self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
    //    self.manager.responseSerializer.acceptableContentTypes = [self.manager.responseSerializer.acceptableContentTypes setByAddingObject:@"text/html"];
    //    [self.manager GET:urlStr parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
    //
    //        NSString *time = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding];
    //        success([time integerValue]);
    //        //        NSLog(@"ServiceTime=%@",time);
    //
    //    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
    //
    //    }];

}複製程式碼

3.2.3 佈局修改

(詳見ZXHeader.h)

整體佈局修改的幾個巨集

/**
 * 價格座標系在右邊?YES->右邊;NO->左邊
 */
#define PriceCoordinateIsInRight YES     

/**
 * 蠟燭的資訊配置的位置:YES->單獨的view顯示在view頂部;NO->彈框覆蓋在蠟燭上
 */
#define IsDisplayCandelInfoInTop NO複製程式碼

約束

  • 其中CandleChartHeight、QuotaChartHeight、MiddleBlankSpace都是可變的,所以分了橫豎屏分別定義;其他尺寸都是固定的。
  • 由於在內部就對各個控制元件的UI進行了組裝,所以就預留了相關的尺寸約束或者顏色巨集,可以在ZXHeader檔案中進行修改,如若有不能修改之處,就只有去ZXAssemblyView.m檔案中進行修改了;

從某種角度上來說,很多約束可以不改,但是巨集中的TotalHeight必須根據專案需求進行修改

3.2.4 橫豎屏適配

小技巧:因為我這裡橫屏之後是全屏並且隱藏了狀態列和導航欄的,為了旋轉之後和豎屏的其他控制元件互不干擾,可以將assenblyView例項新增在self.view的最頂層,然後旋轉過去之後就直接將其他控制元件覆蓋在底層

4 其他問題

  1. 關於歷史k線和socket銜接處暫未進行處理, 銜接還存在誤差;
  2. 未知bug?待挖掘;
  3. k線圖UI很簡單,除了k線沒有其他定製,但是介面都是完善的,主要是覺得關乎UI部分我做得越少,通用性就越高;
  4. 感謝Star;
  5. 有任何其他問題歡迎Issues或者簡書留言;
  6. 超鏈:

相關文章